proconip 2.2.1__tar.gz → 2.3.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.3.1](https://github.com/ylabonte/proconip-pypi/compare/v2.3.0...v2.3.1) (2026-10-12)
4
+
5
+
6
+ ### Documentation
7
+
8
+ * confirm electrolysis bit polarity and document manual-dosage units/limits ([#102](https://github.com/ylabonte/proconip-pypi/issues/102)) ([cb44a11](https://github.com/ylabonte/proconip-pypi/commit/cb44a11336fc73bc9715f9ec12ecffeec8b8d16f))
9
+
10
+ ## [2.3.0](https://github.com/ylabonte/proconip-pypi/compare/v2.2.1...v2.3.0) (2026-10-12)
11
+
12
+
13
+ ### Features
14
+
15
+ * 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))
16
+
3
17
  ## [2.2.1](https://github.com/ylabonte/proconip-pypi/compare/v2.2.0...v2.2.1) (2026-10-08)
4
18
 
5
19
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: proconip
3
- Version: 2.2.1
3
+ Version: 2.3.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/
@@ -165,6 +165,10 @@ asyncio.run(relay_switching_example())
165
165
 
166
166
  Manual dosage depends on the same factors as if started from the web interface
167
167
  of the pool control itself.
168
+ Durations are always in seconds. The controller's web interface allows up to
169
+ 59:59 (3599 s) for liquid dosing and up to 23:59 hours (86340 s, whole minutes)
170
+ for chlorine when a salt electrolysis cell is configured; the library passes
171
+ the value through unchecked.
168
172
 
169
173
  ```python
170
174
  import asyncio
@@ -176,7 +180,7 @@ async def manual_dosage_example():
176
180
  client_session = aiohttp.ClientSession()
177
181
  config = ConfigObject("http://192.168.2.3", "admin", "admin")
178
182
  dosage_control = DosageControl(client_session, config)
179
- await dosage_control.async_chlorine_dosage(3600) # start for 1 hour
183
+ await dosage_control.async_chlorine_dosage(600) # start for 10 minutes
180
184
  await dosage_control.async_ph_minus_dosage(60) # start for 1 minute
181
185
  await client_session.close()
182
186
 
@@ -137,6 +137,10 @@ asyncio.run(relay_switching_example())
137
137
 
138
138
  Manual dosage depends on the same factors as if started from the web interface
139
139
  of the pool control itself.
140
+ Durations are always in seconds. The controller's web interface allows up to
141
+ 59:59 (3599 s) for liquid dosing and up to 23:59 hours (86340 s, whole minutes)
142
+ for chlorine when a salt electrolysis cell is configured; the library passes
143
+ the value through unchecked.
140
144
 
141
145
  ```python
142
146
  import asyncio
@@ -148,7 +152,7 @@ async def manual_dosage_example():
148
152
  client_session = aiohttp.ClientSession()
149
153
  config = ConfigObject("http://192.168.2.3", "admin", "admin")
150
154
  dosage_control = DosageControl(client_session, config)
151
- await dosage_control.async_chlorine_dosage(3600) # start for 1 hour
155
+ await dosage_control.async_chlorine_dosage(600) # start for 10 minutes
152
156
  await dosage_control.async_ph_minus_dosage(60) # start for 1 minute
153
157
  await client_session.close()
154
158
 
@@ -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.1'
22
- __version_tuple__ = version_tuple = (2, 2, 1)
21
+ __version__ = version = '2.3.1'
22
+ __version_tuple__ = version_tuple = (2, 3, 1)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -595,18 +595,37 @@ async def async_start_dosage(
595
595
  """Trigger a manual, time-limited dosage on the controller.
596
596
 
597
597
  Manual dosage is the safe alternative to switching a dosage relay on
598
- directly: the controller still applies the same interlocks it uses for the
599
- web UI (canister level, dosage enabled in config, redox/pH thresholds, …).
600
- If those checks fail, the controller silently no-ops while still returning
601
- HTTP 200 — there is no "dosage refused" error in the protocol.
598
+ directly: it goes through the controller's own dosage logic, which the
599
+ TRM ties to preconditions such as the dosage channel being enabled and
600
+ the assigned filter pump running (a running dosage also stops when the
601
+ filter pump switches off). The TRM's daily maximum dosing amount may also
602
+ stop it: once the channel's daily amount is reached, the controller does
603
+ not dose any further. The protocol has no "dosage refused" response,
604
+ so a successful HTTP exchange does not prove that dosing started; check
605
+ the dosage relay state in `/GetState.csv` if you need to know.
606
+
607
+ The duration is always sent in seconds, as the controller's web UI does
608
+ (``/Command.htm?MAN_DOSAGE=<target>,<seconds>``). That UI limits the
609
+ input to ``mm:ss`` up to 59:59 (3599 s) for liquid dosing (chlorine,
610
+ pH-, pH+) and to ``hh:mm`` up to 23:59 (86340 s, whole minutes) for
611
+ chlorine when a salt electrolysis cell is configured (see
612
+ `GetStateData.is_electrolysis_enabled`). This function passes the value
613
+ through unchecked; how the controller treats values outside those
614
+ limits is not documented.
615
+
616
+ The web UI also stores the duration as the channel's default before it
617
+ sends the command: it rewrites ``MDOSTIME`` in the channel's ``.ini``
618
+ (``usr/rdxcntrl.ini``, ``usr/phcntrl.ini`` or ``usr/phpcntrl.ini``) via
619
+ ``/usrcfg.cgi`` and pre-fills that value next time. This function sends
620
+ only the command and leaves that default untouched.
602
621
 
603
622
  Args:
604
623
  client_session: An open `aiohttp.ClientSession`.
605
624
  config: Controller configuration.
606
625
  dosage_target: Which dosing pump to engage (chlorine, pH-, or pH+).
607
- dosage_duration: Run time in **seconds**. Allowed range depends on the
608
- controller's own dosage configuration; values that exceed the
609
- configured maximum are typically clamped silently by the device.
626
+ dosage_duration: Run time in **seconds**, in both liquid and
627
+ electrolysis mode. The web UI allows 0–3599 s for liquid dosing
628
+ and 0–86340 s (whole minutes) for chlorine electrolysis.
610
629
  timeout: Per-request timeout in seconds.
611
630
 
612
631
  Returns:
@@ -662,10 +681,12 @@ class DosageControl:
662
681
  async def async_chlorine_dosage(
663
682
  self, dosage_duration: int, timeout: float | None = None
664
683
  ) -> str:
665
- """Run the chlorine dosage pump for ``dosage_duration`` seconds.
684
+ """Run the chlorine dosage pump (or electrolysis cell) for ``dosage_duration`` seconds.
666
685
 
667
686
  Args:
668
- dosage_duration: Run time in seconds.
687
+ dosage_duration: Run time in seconds. The controller's web UI
688
+ allows up to 3599 s for liquid chlorine and up to 86340 s
689
+ (whole minutes) for a salt electrolysis cell.
669
690
  timeout: Override for this call only. If ``None``, the timeout
670
691
  bound in `__init__` is used.
671
692
 
@@ -687,7 +708,8 @@ class DosageControl:
687
708
  """Run the pH- dosage pump for ``dosage_duration`` seconds.
688
709
 
689
710
  Args:
690
- dosage_duration: Run time in seconds.
711
+ dosage_duration: Run time in seconds. The controller's web UI
712
+ allows up to 3599 s (59:59).
691
713
  timeout: Override for this call only. If ``None``, the timeout
692
714
  bound in `__init__` is used.
693
715
 
@@ -707,7 +729,8 @@ class DosageControl:
707
729
  """Run the pH+ dosage pump for ``dosage_duration`` seconds.
708
730
 
709
731
  Args:
710
- dosage_duration: Run time in seconds.
732
+ dosage_duration: Run time in seconds. The controller's web UI
733
+ allows up to 3599 s (59:59).
711
734
  timeout: Override for this call only. If ``None``, the timeout
712
735
  bound in `__init__` is used.
713
736
 
@@ -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``.
@@ -620,7 +623,23 @@ class GetStateData:
620
623
  return self._dosage_control & 1 == 1
621
624
 
622
625
  def is_electrolysis_enabled(self) -> bool:
623
- """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: the TRM labels bit 4 "Cl liquid || Elektrolysis" without
636
+ saying which value is which. The controller's own web UI settles it
637
+ (``topleft.htm``, firmware 1.7.6): it decodes
638
+ ``rdx_canister_salt = (dos_cntr >> 4) & 0x01`` and shows
639
+ ``"Flüssig NaClO"`` for 0 and ``"Salz Elektrolyse"`` for 1. A live
640
+ liquid-chlorine controller (``rdxcntrl.ini`` ``DTYPE=0``) reports
641
+ DOSAGE_CNTRL 257 with bit 4 clear, consistent with that.
642
+ """
624
643
  return self._dosage_control & 16 == 16
625
644
 
626
645
  def is_ph_minus_dosage_enabled(self) -> bool:
@@ -651,6 +670,33 @@ class GetStateData:
651
670
  return self.is_ph_plus_dosage_enabled()
652
671
  return False
653
672
 
673
+ def is_electrolysis_object(self, data_entity: DataObject) -> bool:
674
+ """Does this canister/consumption object describe a salt-electrolysis cell?
675
+
676
+ The controller reuses the chlorine canister columns for a salt
677
+ electrolysis setup instead of adding new ones. With electrolysis
678
+ enabled (see `is_electrolysis_enabled`):
679
+
680
+ - column 36 (`chlorine_canister`) is the cell's **remaining lifetime**
681
+ (or maintenance interval) in percent — the share of the configured
682
+ operating hours that is left, not a canister fill level;
683
+ - column 39 (`chlorine_consumption`) is **today's chlorine production**
684
+ calculated from the configured production rate, not a liquid
685
+ volume drawn from a canister.
686
+
687
+ Callers should label and present these objects accordingly (e.g. no
688
+ canister/volume wording). The pH- and pH+ columns are always real
689
+ canisters.
690
+
691
+ Args:
692
+ data_entity: Any `DataObject` from this state.
693
+
694
+ Returns:
695
+ True only for the chlorine canister (column 36) or chlorine
696
+ consumption (column 39) object while electrolysis is enabled.
697
+ """
698
+ return data_entity.column in (36, 39) and self.is_electrolysis_enabled()
699
+
654
700
  def get_dosage_relay(self, data_entity: DataObject) -> int | None:
655
701
  """Aggregated relay ID that handles the dosing for this canister/consumption entity.
656
702
 
@@ -912,20 +958,28 @@ class GetStateData:
912
958
 
913
959
  @property
914
960
  def canister_objects(self) -> list[DataObject]:
915
- """The three canister fill-level readings (columns 36–38).
961
+ """The three canister remaining-content readings (columns 36–38), in percent.
916
962
 
917
963
  Order: chlorine, pH-, pH+. Convenience properties `chlorine_canister`,
918
964
  `ph_minus_canister`, and `ph_plus_canister` return individual entries.
965
+ The values are estimates the controller derives from pump runtime and
966
+ configured delivery rate. With a salt-electrolysis cell the chlorine
967
+ entry is the cell's remaining lifetime instead — check
968
+ `is_electrolysis_object`.
919
969
  """
920
970
  return self._canister_objects
921
971
 
922
972
  @property
923
973
  def consumption_objects(self) -> list[DataObject]:
924
- """The three dosage consumption counters (columns 39–41).
974
+ """The three daily dosage amounts (columns 39–41).
925
975
 
926
976
  Order: chlorine, pH-, pH+. Convenience properties
927
977
  `chlorine_consumption`, `ph_minus_consumption`, and
928
- `ph_plus_consumption` return individual entries.
978
+ `ph_plus_consumption` return individual entries. Each is the amount
979
+ dosed *today* (the controller resets it daily), in the unit the
980
+ controller reports (``ml``; the chlorine column may report ``g``). With a
981
+ salt-electrolysis cell the chlorine entry is today's calculated
982
+ chlorine production — check `is_electrolysis_object`.
929
983
  """
930
984
  return self._consumption_objects
931
985
 
@@ -941,32 +995,41 @@ class GetStateData:
941
995
 
942
996
  @property
943
997
  def chlorine_canister(self) -> DataObject:
944
- """Chlorine canister fill level (column 36)."""
998
+ """Chlorine canister remaining content in percent (column 36).
999
+
1000
+ With a salt-electrolysis cell (`is_electrolysis_enabled`) this is the
1001
+ cell's remaining lifetime / maintenance interval in percent instead.
1002
+ """
945
1003
  return self._canister_objects[0]
946
1004
 
947
1005
  @property
948
1006
  def ph_minus_canister(self) -> DataObject:
949
- """pH- canister fill level (column 37)."""
1007
+ """pH- canister remaining content in percent (column 37)."""
950
1008
  return self._canister_objects[1]
951
1009
 
952
1010
  @property
953
1011
  def ph_plus_canister(self) -> DataObject:
954
- """pH+ canister fill level (column 38)."""
1012
+ """pH+ canister remaining content in percent (column 38)."""
955
1013
  return self._canister_objects[2]
956
1014
 
957
1015
  @property
958
1016
  def chlorine_consumption(self) -> DataObject:
959
- """Cumulative chlorine consumption counter (column 39)."""
1017
+ """Chlorine dosed today (column 39), in ``ml`` or ``g``.
1018
+
1019
+ With a salt-electrolysis cell (`is_electrolysis_enabled`) this is
1020
+ today's chlorine production calculated from the configured
1021
+ production rate instead.
1022
+ """
960
1023
  return self._consumption_objects[0]
961
1024
 
962
1025
  @property
963
1026
  def ph_minus_consumption(self) -> DataObject:
964
- """Cumulative pH- consumption counter (column 40)."""
1027
+ """pH- dosed today (column 40), in ``ml``."""
965
1028
  return self._consumption_objects[1]
966
1029
 
967
1030
  @property
968
1031
  def ph_plus_consumption(self) -> DataObject:
969
- """Cumulative pH+ consumption counter (column 41)."""
1032
+ """pH+ dosed today (column 41), in ``ml``."""
970
1033
  return self._consumption_objects[2]
971
1034
 
972
1035
  @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
@@ -266,6 +266,51 @@ def test_get_dosage_relay(get_state_data: GetStateData) -> None:
266
266
  assert get_state_data.get_dosage_relay(get_state_data.analog_objects[0]) is None
267
267
 
268
268
 
269
+ def test_is_electrolysis_object_false_for_liquid_chlorine(get_state_data: GetStateData) -> None:
270
+ assert not get_state_data.is_electrolysis_enabled()
271
+ for obj in get_state_data.canister_objects + get_state_data.consumption_objects:
272
+ assert not get_state_data.is_electrolysis_object(obj)
273
+
274
+
275
+ def test_is_electrolysis_object_marks_chlorine_columns_only(
276
+ get_state_electrolysis_data: GetStateData,
277
+ ) -> None:
278
+ state = get_state_electrolysis_data
279
+ assert state.is_electrolysis_enabled()
280
+ assert state.is_chlorine_dosage_enabled()
281
+ assert state.is_electrolysis_object(state.chlorine_canister)
282
+ assert state.is_electrolysis_object(state.chlorine_consumption)
283
+ # pH canisters stay real canisters
284
+ for obj in (
285
+ state.ph_minus_canister,
286
+ state.ph_plus_canister,
287
+ state.ph_minus_consumption,
288
+ state.ph_plus_consumption,
289
+ ):
290
+ assert not state.is_electrolysis_object(obj)
291
+ # unrelated columns never qualify
292
+ assert not state.is_electrolysis_object(state.redox_electrode)
293
+ assert not state.is_electrolysis_object(state.analog_objects[0])
294
+
295
+
296
+ def test_electrolysis_keeps_chlorine_columns_positional(
297
+ get_state_electrolysis_data: GetStateData,
298
+ ) -> None:
299
+ """Under electrolysis, columns 36/39 stay in the canister/consumption categories.
300
+
301
+ The ``%``/``g`` units and values below come from the synthesised
302
+ electrolysis fixture, not from observed controller output; the category
303
+ asserts are what this test pins.
304
+ """
305
+ state = get_state_electrolysis_data
306
+ assert state.chlorine_canister.unit == "%"
307
+ assert state.chlorine_canister.value == pytest.approx(87.3)
308
+ assert state.chlorine_canister.category == CATEGORY_CANISTER
309
+ assert state.chlorine_consumption.unit == "g"
310
+ assert state.chlorine_consumption.value == pytest.approx(412)
311
+ assert state.chlorine_consumption.category == CATEGORY_CONSUMPTION
312
+
313
+
269
314
  def test_is_dosage_relay_by_relay_object(get_state_data: GetStateData) -> None:
270
315
  dosage_relay = get_state_data.get_relay(5) # chlorine dosage relay_id
271
316
  assert get_state_data.is_dosage_relay(relay_object=dosage_relay)
File without changes
File without changes
File without changes
File without changes
File without changes