ips-api-client 0.1.0__tar.gz → 0.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.
Files changed (22) hide show
  1. {ips_api_client-0.1.0 → ips_api_client-0.2.0}/CHANGELOG.md +8 -0
  2. {ips_api_client-0.1.0/ips_api_client.egg-info → ips_api_client-0.2.0}/PKG-INFO +3 -1
  3. {ips_api_client-0.1.0 → ips_api_client-0.2.0}/README.md +2 -0
  4. {ips_api_client-0.1.0 → ips_api_client-0.2.0}/ips_api/__init__.py +1 -1
  5. {ips_api_client-0.1.0 → ips_api_client-0.2.0}/ips_api/client.py +7 -1
  6. {ips_api_client-0.1.0 → ips_api_client-0.2.0}/ips_api/models.py +7 -0
  7. {ips_api_client-0.1.0 → ips_api_client-0.2.0}/ips_api/parser.py +51 -8
  8. {ips_api_client-0.1.0 → ips_api_client-0.2.0/ips_api_client.egg-info}/PKG-INFO +3 -1
  9. {ips_api_client-0.1.0 → ips_api_client-0.2.0}/ips_api_client.egg-info/SOURCES.txt +2 -1
  10. {ips_api_client-0.1.0 → ips_api_client-0.2.0}/pyproject.toml +1 -1
  11. {ips_api_client-0.1.0 → ips_api_client-0.2.0}/setup.py +1 -1
  12. {ips_api_client-0.1.0 → ips_api_client-0.2.0}/tests/test_client.py +1 -0
  13. ips_api_client-0.2.0/tests/test_parser.py +70 -0
  14. {ips_api_client-0.1.0 → ips_api_client-0.2.0}/LICENSE +0 -0
  15. {ips_api_client-0.1.0 → ips_api_client-0.2.0}/MANIFEST.in +0 -0
  16. {ips_api_client-0.1.0 → ips_api_client-0.2.0}/ips_api/const.py +0 -0
  17. {ips_api_client-0.1.0 → ips_api_client-0.2.0}/ips_api/exceptions.py +0 -0
  18. {ips_api_client-0.1.0 → ips_api_client-0.2.0}/ips_api_client.egg-info/dependency_links.txt +0 -0
  19. {ips_api_client-0.1.0 → ips_api_client-0.2.0}/ips_api_client.egg-info/requires.txt +0 -0
  20. {ips_api_client-0.1.0 → ips_api_client-0.2.0}/ips_api_client.egg-info/top_level.txt +0 -0
  21. {ips_api_client-0.1.0 → ips_api_client-0.2.0}/requirements.txt +0 -0
  22. {ips_api_client-0.1.0 → ips_api_client-0.2.0}/setup.cfg +0 -0
@@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.2.0] - 2026-07-11
9
+
10
+ ### Added
11
+ - Parse the ORP live state string (`lblorpState`) into `PoolReading.orp_state`.
12
+ - Detect the live overfeed lockout condition per channel via
13
+ `PoolReading.ph_overfeed` / `orp_overfeed` (tri-state: True/False/None) and the
14
+ `state_is_overfeed()` helper.
15
+
8
16
  ## [0.1.0] - 2025-10-21
9
17
 
10
18
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ips-api-client
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Async Python client for IPS Controllers pool monitoring system
5
5
  Home-page: https://github.com/stgarrity/ips-api-client
6
6
  Author: Steve Garrity
@@ -62,6 +62,7 @@ async def main():
62
62
  # Get detailed reading
63
63
  detail = await client.get_controller_detail(controller.controller_id)
64
64
  print(f" pH Setpoint: {detail.ph_setpoint}")
65
+ print(f" ORP Setpoint: {detail.orp_setpoint}")
65
66
  print(f" pH State: {detail.ph_state}")
66
67
 
67
68
  asyncio.run(main())
@@ -84,6 +85,7 @@ asyncio.run(main())
84
85
  - Controller status
85
86
  - Last reading timestamp
86
87
  - pH setpoint and state
88
+ - ORP setpoint
87
89
 
88
90
  ## License
89
91
 
@@ -28,6 +28,7 @@ async def main():
28
28
  # Get detailed reading
29
29
  detail = await client.get_controller_detail(controller.controller_id)
30
30
  print(f" pH Setpoint: {detail.ph_setpoint}")
31
+ print(f" ORP Setpoint: {detail.orp_setpoint}")
31
32
  print(f" pH State: {detail.ph_state}")
32
33
 
33
34
  asyncio.run(main())
@@ -50,6 +51,7 @@ asyncio.run(main())
50
51
  - Controller status
51
52
  - Last reading timestamp
52
53
  - pH setpoint and state
54
+ - ORP setpoint
53
55
 
54
56
  ## License
55
57
 
@@ -1,6 +1,6 @@
1
1
  """IPS Controllers API Client Library."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.1.1"
4
4
 
5
5
  from .client import IPSClient
6
6
  from .models import PoolController, PoolReading, ControllerStatus
@@ -213,7 +213,13 @@ class IPSClient:
213
213
  raise SessionExpiredError("Session has expired")
214
214
 
215
215
  reading = parse_device_detail(html)
216
- _LOGGER.debug(f"Retrieved reading: pH={reading.ph}, ORP={reading.orp}")
216
+ _LOGGER.debug(
217
+ "Retrieved reading: pH=%s, ORP=%s, pH setpoint=%s, ORP setpoint=%s",
218
+ reading.ph,
219
+ reading.orp,
220
+ reading.ph_setpoint,
221
+ reading.orp_setpoint,
222
+ )
217
223
 
218
224
  return reading
219
225
 
@@ -17,7 +17,14 @@ class PoolReading:
17
17
 
18
18
  # Additional details (from detail page)
19
19
  ph_state: Optional[str] = None
20
+ orp_state: Optional[str] = None
20
21
  ph_setpoint: Optional[float] = None
22
+ orp_setpoint: Optional[int] = None
23
+
24
+ # Live overfeed lockout condition per channel.
25
+ # True = in overfeed, False = known not in overfeed, None = state unknown.
26
+ ph_overfeed: Optional[bool] = None
27
+ orp_overfeed: Optional[bool] = None
21
28
 
22
29
 
23
30
  @dataclass
@@ -112,6 +112,39 @@ def parse_int(value: str) -> Optional[int]:
112
112
  return None
113
113
 
114
114
 
115
+ def extract_first_number(value: str) -> Optional[str]:
116
+ """Extract the first numeric token from a string."""
117
+ if not value or not value.strip():
118
+ return None
119
+
120
+ match = re.search(r'([0-9]+(?:\.[0-9]+)?)', value)
121
+ if not match:
122
+ return None
123
+
124
+ return match.group(1)
125
+
126
+
127
+ def state_is_overfeed(state: Optional[str]) -> Optional[bool]:
128
+ """Determine if a live pH/ORP state string reports an overfeed lockout.
129
+
130
+ The controller state is a compound, dash/comma separated string (e.g.
131
+ "Normal - No Flow" or "Overfeed - No Flow"). When the unit exceeds its feed
132
+ limit it locks out dosing and adds an "Overfeed" token to this string.
133
+
134
+ Args:
135
+ state: Live state string from the detail page, or None.
136
+
137
+ Returns:
138
+ True if the state reports overfeed, False if the state is known and does
139
+ not report overfeed, or None if the state is missing/unknown.
140
+ """
141
+ if state is None or not state.strip():
142
+ return None
143
+
144
+ normalized = re.sub(r'\s+', '', state).lower()
145
+ return 'overfeed' in normalized
146
+
147
+
115
148
  def extract_status_from_icon(img_src: str) -> str:
116
149
  """Extract status from icon filename.
117
150
 
@@ -244,23 +277,33 @@ def parse_device_detail(html: str) -> PoolReading:
244
277
  orp_elem = soup.find('span', id='lblOrpNum')
245
278
  temp_elem = soup.find('span', id='lblTempValue')
246
279
  ph_state_elem = soup.find('span', id='lblpHState')
280
+ orp_state_elem = soup.find('span', id='lblorpState')
247
281
  ph_setpoint_elem = soup.find('span', id='lblphSetpoint')
282
+ orp_setpoint_elem = soup.find('span', id='lblorpSetpoint')
248
283
  timestamp_elem = soup.find('span', id='lblLastReading')
249
284
 
250
- # Extract pH setpoint from text like "Setpoint: 7.3"
251
- ph_setpoint = None
252
- if ph_setpoint_elem:
253
- setpoint_text = ph_setpoint_elem.get_text()
254
- match = re.search(r'([0-9.]+)', setpoint_text)
255
- if match:
256
- ph_setpoint = parse_float(match.group(1))
285
+ ph_setpoint = parse_float(
286
+ extract_first_number(ph_setpoint_elem.get_text() if ph_setpoint_elem else '')
287
+ or ''
288
+ )
289
+ orp_setpoint = parse_int(
290
+ extract_first_number(orp_setpoint_elem.get_text() if orp_setpoint_elem else '')
291
+ or ''
292
+ )
293
+
294
+ ph_state = ph_state_elem.get_text(strip=True) if ph_state_elem else None
295
+ orp_state = orp_state_elem.get_text(strip=True) if orp_state_elem else None
257
296
 
258
297
  reading = PoolReading(
259
298
  ph=parse_float(ph_elem.get_text() if ph_elem else ''),
260
299
  orp=parse_int(orp_elem.get_text() if orp_elem else ''),
261
300
  temperature=parse_float(temp_elem.get_text() if temp_elem else ''),
262
- ph_state=ph_state_elem.get_text(strip=True) if ph_state_elem else None,
301
+ ph_state=ph_state,
302
+ orp_state=orp_state,
263
303
  ph_setpoint=ph_setpoint,
304
+ orp_setpoint=orp_setpoint,
305
+ ph_overfeed=state_is_overfeed(ph_state),
306
+ orp_overfeed=state_is_overfeed(orp_state),
264
307
  timestamp=parse_datetime(timestamp_elem.get_text() if timestamp_elem else ''),
265
308
  )
266
309
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ips-api-client
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Async Python client for IPS Controllers pool monitoring system
5
5
  Home-page: https://github.com/stgarrity/ips-api-client
6
6
  Author: Steve Garrity
@@ -62,6 +62,7 @@ async def main():
62
62
  # Get detailed reading
63
63
  detail = await client.get_controller_detail(controller.controller_id)
64
64
  print(f" pH Setpoint: {detail.ph_setpoint}")
65
+ print(f" ORP Setpoint: {detail.orp_setpoint}")
65
66
  print(f" pH State: {detail.ph_state}")
66
67
 
67
68
  asyncio.run(main())
@@ -84,6 +85,7 @@ asyncio.run(main())
84
85
  - Controller status
85
86
  - Last reading timestamp
86
87
  - pH setpoint and state
88
+ - ORP setpoint
87
89
 
88
90
  ## License
89
91
 
@@ -16,4 +16,5 @@ ips_api_client.egg-info/SOURCES.txt
16
16
  ips_api_client.egg-info/dependency_links.txt
17
17
  ips_api_client.egg-info/requires.txt
18
18
  ips_api_client.egg-info/top_level.txt
19
- tests/test_client.py
19
+ tests/test_client.py
20
+ tests/test_parser.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ips-api-client"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  authors = [
9
9
  {name = "Steve Garrity", email = "sgarrity@gmail.com"}
10
10
  ]
@@ -5,7 +5,7 @@ with open("README.md", "r", encoding="utf-8") as fh:
5
5
 
6
6
  setup(
7
7
  name="ips-api-client",
8
- version="0.1.0",
8
+ version="0.2.0",
9
9
  author="Steve Garrity",
10
10
  author_email="sgarrity@gmail.com",
11
11
  description="API client for IPS Controllers pool monitoring system",
@@ -68,6 +68,7 @@ async def main():
68
68
  print(f" Temperature: {detail.temperature}")
69
69
  print(f" pH State: {detail.ph_state}")
70
70
  print(f" pH Setpoint: {detail.ph_setpoint}")
71
+ print(f" ORP Setpoint: {detail.orp_setpoint}")
71
72
  print(f" Timestamp: {detail.timestamp}")
72
73
 
73
74
  print("\n" + "=" * 80)
@@ -0,0 +1,70 @@
1
+ """Unit tests for DeviceDetail parsing and overfeed detection."""
2
+
3
+ import os
4
+ import sys
5
+
6
+ import pytest
7
+
8
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
9
+
10
+ from ips_api.parser import parse_device_detail, state_is_overfeed
11
+
12
+ FIXTURES = os.path.join(os.path.dirname(__file__), 'fixtures')
13
+
14
+
15
+ def _load(name: str) -> str:
16
+ with open(os.path.join(FIXTURES, name)) as f:
17
+ return f.read()
18
+
19
+
20
+ @pytest.mark.parametrize(
21
+ "state, expected",
22
+ [
23
+ ("Overfeed - No Flow", True),
24
+ ("Demand, Overfeed - No Flow", True),
25
+ ("over feed", True), # spacing/case insensitive
26
+ ("OVERFEED", True),
27
+ ("Normal - No Flow", False),
28
+ ("Low Alert - No Demand - No Flow", False),
29
+ ("", None),
30
+ (" ", None),
31
+ (None, None),
32
+ ],
33
+ )
34
+ def test_state_is_overfeed(state, expected):
35
+ assert state_is_overfeed(state) is expected
36
+
37
+
38
+ def test_parse_device_detail_normal():
39
+ reading = parse_device_detail(_load('device_detail_normal.html'))
40
+
41
+ assert reading.ph == 7.20
42
+ assert reading.orp == 725
43
+ assert reading.temperature == 82
44
+ assert reading.ph_setpoint == 7.3
45
+ assert reading.orp_setpoint == 740
46
+ assert reading.ph_state == "Normal - No Flow"
47
+ assert reading.orp_state == "Demand, In Delay - No Flow"
48
+ # No overfeed, but state is known -> False (not None)
49
+ assert reading.ph_overfeed is False
50
+ assert reading.orp_overfeed is False
51
+
52
+
53
+ def test_parse_device_detail_overfeed():
54
+ reading = parse_device_detail(_load('device_detail_overfeed.html'))
55
+
56
+ assert reading.ph_state == "Overfeed - No Flow"
57
+ assert reading.orp_state == "Demand, Overfeed - No Flow"
58
+ assert reading.ph_overfeed is True
59
+ assert reading.orp_overfeed is True
60
+
61
+
62
+ def test_parse_device_detail_missing_state():
63
+ # No state spans at all -> overfeed flags are None (unknown), not False
64
+ html = '<html><body><span id="lblphNum">7.2</span></body></html>'
65
+ reading = parse_device_detail(html)
66
+
67
+ assert reading.ph_state is None
68
+ assert reading.orp_state is None
69
+ assert reading.ph_overfeed is None
70
+ assert reading.orp_overfeed is None
File without changes
File without changes