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.
- {ips_api_client-0.1.0 → ips_api_client-0.2.0}/CHANGELOG.md +8 -0
- {ips_api_client-0.1.0/ips_api_client.egg-info → ips_api_client-0.2.0}/PKG-INFO +3 -1
- {ips_api_client-0.1.0 → ips_api_client-0.2.0}/README.md +2 -0
- {ips_api_client-0.1.0 → ips_api_client-0.2.0}/ips_api/__init__.py +1 -1
- {ips_api_client-0.1.0 → ips_api_client-0.2.0}/ips_api/client.py +7 -1
- {ips_api_client-0.1.0 → ips_api_client-0.2.0}/ips_api/models.py +7 -0
- {ips_api_client-0.1.0 → ips_api_client-0.2.0}/ips_api/parser.py +51 -8
- {ips_api_client-0.1.0 → ips_api_client-0.2.0/ips_api_client.egg-info}/PKG-INFO +3 -1
- {ips_api_client-0.1.0 → ips_api_client-0.2.0}/ips_api_client.egg-info/SOURCES.txt +2 -1
- {ips_api_client-0.1.0 → ips_api_client-0.2.0}/pyproject.toml +1 -1
- {ips_api_client-0.1.0 → ips_api_client-0.2.0}/setup.py +1 -1
- {ips_api_client-0.1.0 → ips_api_client-0.2.0}/tests/test_client.py +1 -0
- ips_api_client-0.2.0/tests/test_parser.py +70 -0
- {ips_api_client-0.1.0 → ips_api_client-0.2.0}/LICENSE +0 -0
- {ips_api_client-0.1.0 → ips_api_client-0.2.0}/MANIFEST.in +0 -0
- {ips_api_client-0.1.0 → ips_api_client-0.2.0}/ips_api/const.py +0 -0
- {ips_api_client-0.1.0 → ips_api_client-0.2.0}/ips_api/exceptions.py +0 -0
- {ips_api_client-0.1.0 → ips_api_client-0.2.0}/ips_api_client.egg-info/dependency_links.txt +0 -0
- {ips_api_client-0.1.0 → ips_api_client-0.2.0}/ips_api_client.egg-info/requires.txt +0 -0
- {ips_api_client-0.1.0 → ips_api_client-0.2.0}/ips_api_client.egg-info/top_level.txt +0 -0
- {ips_api_client-0.1.0 → ips_api_client-0.2.0}/requirements.txt +0 -0
- {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.
|
|
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
|
|
|
@@ -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(
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
if
|
|
256
|
-
|
|
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=
|
|
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.
|
|
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
|
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|