ips-api-client 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """IPS Controllers API Client Library."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .client import IPSClient
6
+ from .models import PoolController, PoolReading, ControllerStatus
7
+ from .exceptions import (
8
+ IPSError,
9
+ AuthenticationError,
10
+ SessionExpiredError,
11
+ ControllerNotFoundError,
12
+ )
13
+
14
+ __all__ = [
15
+ "IPSClient",
16
+ "PoolController",
17
+ "PoolReading",
18
+ "ControllerStatus",
19
+ "IPSError",
20
+ "AuthenticationError",
21
+ "SessionExpiredError",
22
+ "ControllerNotFoundError",
23
+ ]
ips_api/client.py ADDED
@@ -0,0 +1,238 @@
1
+ """Main IPS Controllers API client."""
2
+
3
+ import logging
4
+ from typing import Dict, List, Optional
5
+
6
+ import aiohttp
7
+
8
+ from .const import (
9
+ BASE_URL,
10
+ DEFAULT_TIMEOUT,
11
+ DEVICE_DETAIL_URL,
12
+ LOGIN_URL,
13
+ LOGOUT_URL,
14
+ MY_DEVICES_URL,
15
+ )
16
+ from .exceptions import AuthenticationError, ControllerNotFoundError, SessionExpiredError
17
+ from .models import PoolController, PoolReading
18
+ from .parser import (
19
+ extract_viewstate_tokens,
20
+ parse_controllers_list,
21
+ parse_device_detail,
22
+ )
23
+
24
+ _LOGGER = logging.getLogger(__name__)
25
+
26
+
27
+ class IPSClient:
28
+ """Client for IPS Controllers monitoring system."""
29
+
30
+ def __init__(
31
+ self,
32
+ username: str,
33
+ password: str,
34
+ session: Optional[aiohttp.ClientSession] = None,
35
+ timeout: int = DEFAULT_TIMEOUT,
36
+ ):
37
+ """Initialize the IPS client.
38
+
39
+ Args:
40
+ username: IPS account email
41
+ password: IPS account password
42
+ session: Optional aiohttp session (will create one if not provided)
43
+ timeout: Request timeout in seconds
44
+ """
45
+ self.username = username
46
+ self.password = password
47
+ self._session = session
48
+ self._owned_session = session is None
49
+ self._timeout = timeout
50
+ self._authenticated = False
51
+
52
+ async def __aenter__(self):
53
+ """Async context manager entry."""
54
+ if self._owned_session:
55
+ self._session = aiohttp.ClientSession()
56
+ return self
57
+
58
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
59
+ """Async context manager exit."""
60
+ await self.close()
61
+
62
+ async def close(self):
63
+ """Close the client session."""
64
+ if self._owned_session and self._session:
65
+ await self._session.close()
66
+ self._session = None
67
+
68
+ async def _get_viewstate_tokens(self) -> Dict[str, str]:
69
+ """Get ViewState tokens from login page.
70
+
71
+ Returns:
72
+ Dictionary with ViewState tokens
73
+ """
74
+ async with self._session.get(LOGIN_URL, timeout=self._timeout) as response:
75
+ response.raise_for_status()
76
+ html = await response.text()
77
+ return extract_viewstate_tokens(html)
78
+
79
+ async def login(self) -> bool:
80
+ """Login to IPS Controllers.
81
+
82
+ Returns:
83
+ True if login successful
84
+
85
+ Raises:
86
+ AuthenticationError: If login fails
87
+ """
88
+ _LOGGER.debug("Logging in to IPS Controllers")
89
+
90
+ # Get ViewState tokens
91
+ tokens = await self._get_viewstate_tokens()
92
+
93
+ if not all(tokens.values()):
94
+ raise AuthenticationError("Failed to extract ViewState tokens")
95
+
96
+ # Prepare login form
97
+ form_data = {
98
+ '__VIEWSTATE': tokens['__VIEWSTATE'],
99
+ '__VIEWSTATEGENERATOR': tokens['__VIEWSTATEGENERATOR'],
100
+ '__EVENTVALIDATION': tokens['__EVENTVALIDATION'],
101
+ 'txtLoginName': self.username,
102
+ 'txtLoginPassword': self.password,
103
+ 'imgbtnLogin.x': '40',
104
+ 'imgbtnLogin.y': '32',
105
+ }
106
+
107
+ # Submit login
108
+ async with self._session.post(
109
+ LOGIN_URL,
110
+ data=form_data,
111
+ allow_redirects=True,
112
+ timeout=self._timeout,
113
+ ) as response:
114
+ response.raise_for_status()
115
+ final_url = str(response.url)
116
+
117
+ # Check if redirected away from login page
118
+ if 'Login.aspx' in final_url:
119
+ _LOGGER.error("Login failed - still on login page")
120
+ raise AuthenticationError("Invalid username or password")
121
+
122
+ _LOGGER.info("Successfully logged in to IPS Controllers")
123
+ self._authenticated = True
124
+ return True
125
+
126
+ async def logout(self):
127
+ """Logout from IPS Controllers."""
128
+ if not self._authenticated:
129
+ return
130
+
131
+ try:
132
+ async with self._session.get(LOGOUT_URL, timeout=self._timeout) as response:
133
+ response.raise_for_status()
134
+ _LOGGER.info("Logged out from IPS Controllers")
135
+ except Exception as e:
136
+ _LOGGER.warning(f"Error during logout: {e}")
137
+ finally:
138
+ self._authenticated = False
139
+
140
+ async def _ensure_authenticated(self):
141
+ """Ensure we're authenticated, login if needed."""
142
+ if not self._authenticated:
143
+ await self.login()
144
+
145
+ async def _check_session_expired(self, html: str) -> bool:
146
+ """Check if session has expired by looking for login form.
147
+
148
+ Args:
149
+ html: Response HTML
150
+
151
+ Returns:
152
+ True if session expired
153
+ """
154
+ # If we see login form elements, session expired
155
+ return 'txtLoginName' in html or 'txtLoginPassword' in html
156
+
157
+ async def get_controllers(self) -> List[PoolController]:
158
+ """Get list of all controllers.
159
+
160
+ Returns:
161
+ List of PoolController objects
162
+
163
+ Raises:
164
+ SessionExpiredError: If session has expired
165
+ """
166
+ await self._ensure_authenticated()
167
+
168
+ _LOGGER.debug("Fetching controllers list")
169
+
170
+ async with self._session.get(MY_DEVICES_URL, timeout=self._timeout) as response:
171
+ response.raise_for_status()
172
+ html = await response.text()
173
+
174
+ # Check if session expired
175
+ if await self._check_session_expired(html):
176
+ self._authenticated = False
177
+ raise SessionExpiredError("Session has expired")
178
+
179
+ controllers = parse_controllers_list(html)
180
+ _LOGGER.info(f"Found {len(controllers)} controller(s)")
181
+
182
+ return controllers
183
+
184
+ async def get_controller_detail(self, controller_id: str) -> PoolReading:
185
+ """Get detailed readings for a specific controller.
186
+
187
+ Args:
188
+ controller_id: Controller ID
189
+
190
+ Returns:
191
+ PoolReading with detailed information
192
+
193
+ Raises:
194
+ SessionExpiredError: If session has expired
195
+ ControllerNotFoundError: If controller not found
196
+ """
197
+ await self._ensure_authenticated()
198
+
199
+ _LOGGER.debug(f"Fetching details for controller {controller_id}")
200
+
201
+ url = f"{DEVICE_DETAIL_URL}?Controller={controller_id}"
202
+
203
+ async with self._session.get(url, timeout=self._timeout) as response:
204
+ if response.status == 404:
205
+ raise ControllerNotFoundError(f"Controller {controller_id} not found")
206
+
207
+ response.raise_for_status()
208
+ html = await response.text()
209
+
210
+ # Check if session expired
211
+ if await self._check_session_expired(html):
212
+ self._authenticated = False
213
+ raise SessionExpiredError("Session has expired")
214
+
215
+ reading = parse_device_detail(html)
216
+ _LOGGER.debug(f"Retrieved reading: pH={reading.ph}, ORP={reading.orp}")
217
+
218
+ return reading
219
+
220
+ async def get_all_readings(self) -> Dict[str, PoolReading]:
221
+ """Get detailed readings for all controllers.
222
+
223
+ Returns:
224
+ Dictionary mapping controller_id to PoolReading
225
+ """
226
+ controllers = await self.get_controllers()
227
+
228
+ readings = {}
229
+ for controller in controllers:
230
+ try:
231
+ reading = await self.get_controller_detail(controller.controller_id)
232
+ readings[controller.controller_id] = reading
233
+ except Exception as e:
234
+ _LOGGER.warning(
235
+ f"Failed to get reading for controller {controller.name}: {e}"
236
+ )
237
+
238
+ return readings
ips_api/const.py ADDED
@@ -0,0 +1,25 @@
1
+ """Constants for IPS Controllers API."""
2
+
3
+ BASE_URL = "https://monitor.ipscontrollers.com"
4
+
5
+ # Endpoints
6
+ LOGIN_URL = f"{BASE_URL}/Login.aspx"
7
+ MY_DEVICES_URL = f"{BASE_URL}/MyDevices.aspx"
8
+ DEVICE_DETAIL_URL = f"{BASE_URL}/DeviceDetail.aspx"
9
+ LOGOUT_URL = f"{BASE_URL}/Logout.aspx"
10
+
11
+ # Default settings
12
+ DEFAULT_TIMEOUT = 30
13
+ DEFAULT_POLL_INTERVAL = 900 # 15 minutes
14
+
15
+ # Status indicators (from image filenames)
16
+ STATUS_NORMAL = "normal"
17
+ STATUS_ALERT = "alert"
18
+ STATUS_WARNING = "warning"
19
+ STATUS_OFFLINE = "offline"
20
+
21
+ STATUS_ICONS = {
22
+ "icon_green_light.png": STATUS_NORMAL,
23
+ "icon_red_light.png": STATUS_ALERT,
24
+ "icon_yellow_light.png": STATUS_WARNING,
25
+ }
ips_api/exceptions.py ADDED
@@ -0,0 +1,26 @@
1
+ """Custom exceptions for IPS Controllers API."""
2
+
3
+
4
+ class IPSError(Exception):
5
+ """Base exception for IPS API errors."""
6
+ pass
7
+
8
+
9
+ class AuthenticationError(IPSError):
10
+ """Raised when authentication fails."""
11
+ pass
12
+
13
+
14
+ class SessionExpiredError(IPSError):
15
+ """Raised when the session has expired."""
16
+ pass
17
+
18
+
19
+ class ControllerNotFoundError(IPSError):
20
+ """Raised when a controller is not found."""
21
+ pass
22
+
23
+
24
+ class ParseError(IPSError):
25
+ """Raised when HTML parsing fails."""
26
+ pass
ips_api/models.py ADDED
@@ -0,0 +1,41 @@
1
+ """Data models for IPS Controllers."""
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from typing import Optional
6
+
7
+
8
+ @dataclass
9
+ class PoolReading:
10
+ """Pool chemistry reading."""
11
+
12
+ ph: Optional[float] = None
13
+ orp: Optional[int] = None # Oxidation-Reduction Potential in mV
14
+ temperature: Optional[float] = None
15
+ ppm: Optional[float] = None # Parts per million (chlorine)
16
+ timestamp: Optional[datetime] = None
17
+
18
+ # Additional details (from detail page)
19
+ ph_state: Optional[str] = None
20
+ ph_setpoint: Optional[float] = None
21
+
22
+
23
+ @dataclass
24
+ class PoolController:
25
+ """Pool controller device."""
26
+
27
+ controller_id: str
28
+ name: str
29
+ status: str # normal, alert, warning, offline
30
+ current_reading: Optional[PoolReading] = None
31
+ last_reading_time: Optional[datetime] = None
32
+ last_alert_time: Optional[datetime] = None
33
+
34
+
35
+ @dataclass
36
+ class ControllerStatus:
37
+ """Overall status information."""
38
+
39
+ is_online: bool
40
+ status_code: str # normal, alert, warning, offline
41
+ status_message: Optional[str] = None
ips_api/parser.py ADDED
@@ -0,0 +1,267 @@
1
+ """HTML parsing utilities for IPS Controllers."""
2
+
3
+ import re
4
+ from datetime import datetime
5
+ from html.parser import HTMLParser
6
+ from typing import Dict, List, Optional
7
+
8
+ from bs4 import BeautifulSoup
9
+
10
+ from .const import STATUS_ICONS, STATUS_OFFLINE
11
+ from .exceptions import ParseError
12
+ from .models import PoolController, PoolReading
13
+
14
+
15
+ class ViewStateParser(HTMLParser):
16
+ """Parse ASP.NET ViewState tokens from HTML."""
17
+
18
+ def __init__(self):
19
+ super().__init__()
20
+ self.viewstate = None
21
+ self.viewstate_generator = None
22
+ self.event_validation = None
23
+
24
+ def handle_starttag(self, tag, attrs):
25
+ attrs_dict = dict(attrs)
26
+ if tag == 'input':
27
+ input_id = attrs_dict.get('id', '')
28
+ input_value = attrs_dict.get('value', '')
29
+
30
+ if input_id == '__VIEWSTATE':
31
+ self.viewstate = input_value
32
+ elif input_id == '__VIEWSTATEGENERATOR':
33
+ self.viewstate_generator = input_value
34
+ elif input_id == '__EVENTVALIDATION':
35
+ self.event_validation = input_value
36
+
37
+
38
+ def extract_viewstate_tokens(html: str) -> Dict[str, str]:
39
+ """Extract ASP.NET ViewState tokens from HTML.
40
+
41
+ Args:
42
+ html: HTML content
43
+
44
+ Returns:
45
+ Dictionary with __VIEWSTATE, __VIEWSTATEGENERATOR, __EVENTVALIDATION
46
+ """
47
+ parser = ViewStateParser()
48
+ parser.feed(html)
49
+
50
+ return {
51
+ '__VIEWSTATE': parser.viewstate,
52
+ '__VIEWSTATEGENERATOR': parser.viewstate_generator,
53
+ '__EVENTVALIDATION': parser.event_validation,
54
+ }
55
+
56
+
57
+ def parse_datetime(date_str: str) -> Optional[datetime]:
58
+ """Parse datetime from IPS format.
59
+
60
+ Args:
61
+ date_str: Date string like "10/21/2025 11:20:21 AM"
62
+
63
+ Returns:
64
+ datetime object or None if parsing fails
65
+ """
66
+ if not date_str or not date_str.strip():
67
+ return None
68
+
69
+ try:
70
+ return datetime.strptime(date_str.strip(), "%m/%d/%Y %I:%M:%S %p")
71
+ except ValueError:
72
+ try:
73
+ # Try without seconds
74
+ return datetime.strptime(date_str.strip(), "%m/%d/%Y %I:%M %p")
75
+ except ValueError:
76
+ return None
77
+
78
+
79
+ def parse_float(value: str) -> Optional[float]:
80
+ """Parse float value, handling empty strings.
81
+
82
+ Args:
83
+ value: String value
84
+
85
+ Returns:
86
+ Float or None
87
+ """
88
+ if not value or not value.strip():
89
+ return None
90
+
91
+ try:
92
+ return float(value.strip())
93
+ except ValueError:
94
+ return None
95
+
96
+
97
+ def parse_int(value: str) -> Optional[int]:
98
+ """Parse int value, handling empty strings.
99
+
100
+ Args:
101
+ value: String value
102
+
103
+ Returns:
104
+ Int or None
105
+ """
106
+ if not value or not value.strip():
107
+ return None
108
+
109
+ try:
110
+ return int(value.strip())
111
+ except ValueError:
112
+ return None
113
+
114
+
115
+ def extract_status_from_icon(img_src: str) -> str:
116
+ """Extract status from icon filename.
117
+
118
+ Args:
119
+ img_src: Image source path
120
+
121
+ Returns:
122
+ Status code
123
+ """
124
+ for icon_name, status in STATUS_ICONS.items():
125
+ if icon_name in img_src:
126
+ return status
127
+ return STATUS_OFFLINE
128
+
129
+
130
+ def parse_controllers_list(html: str) -> List[PoolController]:
131
+ """Parse list of controllers from MyDevices.aspx.
132
+
133
+ Args:
134
+ html: HTML content from MyDevices.aspx
135
+
136
+ Returns:
137
+ List of PoolController objects
138
+ """
139
+ soup = BeautifulSoup(html, 'html.parser')
140
+ controllers = []
141
+
142
+ # Find all device links
143
+ device_links = soup.find_all('a', href=re.compile(r'DeviceDetail\.aspx\?Controller='))
144
+
145
+ for link in device_links:
146
+ try:
147
+ # Extract controller ID from URL
148
+ href = link.get('href', '')
149
+ match = re.search(r'Controller=([^&]+)', href)
150
+ if not match:
151
+ continue
152
+
153
+ controller_id = match.group(1)
154
+ name = link.get_text(strip=True)
155
+
156
+ # Find the parent container to get associated data
157
+ parent = link.find_parent('td')
158
+ if not parent:
159
+ parent = link.find_parent('div')
160
+
161
+ if not parent:
162
+ continue
163
+
164
+ # Look for status icon (sibling or nearby)
165
+ status_img = parent.find_next('img', src=re.compile(r'icon_.*_light\.png'))
166
+ status = STATUS_OFFLINE
167
+ if status_img:
168
+ status = extract_status_from_icon(status_img.get('src', ''))
169
+
170
+ # Look for data spans - they have specific IDs
171
+ # Pattern: dlLocations_ctl00_dlDevices_ctl01_lblDevicepH
172
+ parent_container = link.find_parent('tr') or link.find_parent(['table', 'div'], recursive=True)
173
+
174
+ ph_span = None
175
+ orp_span = None
176
+ ppm_span = None
177
+ temp_span = None
178
+ checked_span = None
179
+ alert_span = None
180
+
181
+ if parent_container:
182
+ # Find all spans in the container
183
+ spans = parent_container.find_all('span')
184
+ for span in spans:
185
+ span_id = span.get('id', '')
186
+ if 'lblDevicepH' in span_id:
187
+ ph_span = span
188
+ elif 'lblDeviceORP' in span_id:
189
+ orp_span = span
190
+ elif 'lblPPM' in span_id:
191
+ ppm_span = span
192
+ elif 'lblTemp' in span_id:
193
+ temp_span = span
194
+ elif 'lblDeviceChecked' in span_id:
195
+ checked_span = span
196
+ elif 'lblAlertReading' in span_id:
197
+ alert_span = span
198
+
199
+ # Parse values
200
+ reading = PoolReading(
201
+ ph=parse_float(ph_span.get_text() if ph_span else ''),
202
+ orp=parse_int(orp_span.get_text() if orp_span else ''),
203
+ ppm=parse_float(ppm_span.get_text() if ppm_span else ''),
204
+ temperature=parse_float(temp_span.get_text() if temp_span else ''),
205
+ )
206
+
207
+ last_reading_time = parse_datetime(checked_span.get_text() if checked_span else '')
208
+ last_alert_time = parse_datetime(alert_span.get_text() if alert_span else '')
209
+
210
+ controller = PoolController(
211
+ controller_id=controller_id,
212
+ name=name,
213
+ status=status,
214
+ current_reading=reading,
215
+ last_reading_time=last_reading_time,
216
+ last_alert_time=last_alert_time,
217
+ )
218
+
219
+ controllers.append(controller)
220
+
221
+ except Exception as e:
222
+ # Skip controllers that fail to parse
223
+ continue
224
+
225
+ if not controllers:
226
+ raise ParseError("No controllers found in HTML")
227
+
228
+ return controllers
229
+
230
+
231
+ def parse_device_detail(html: str) -> PoolReading:
232
+ """Parse detailed readings from DeviceDetail.aspx.
233
+
234
+ Args:
235
+ html: HTML content from DeviceDetail.aspx
236
+
237
+ Returns:
238
+ PoolReading object with detailed information
239
+ """
240
+ soup = BeautifulSoup(html, 'html.parser')
241
+
242
+ # Find specific labeled elements
243
+ ph_elem = soup.find('span', id='lblphNum')
244
+ orp_elem = soup.find('span', id='lblOrpNum')
245
+ temp_elem = soup.find('span', id='lblTempValue')
246
+ ph_state_elem = soup.find('span', id='lblpHState')
247
+ ph_setpoint_elem = soup.find('span', id='lblphSetpoint')
248
+ timestamp_elem = soup.find('span', id='lblLastReading')
249
+
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))
257
+
258
+ reading = PoolReading(
259
+ ph=parse_float(ph_elem.get_text() if ph_elem else ''),
260
+ orp=parse_int(orp_elem.get_text() if orp_elem else ''),
261
+ 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,
263
+ ph_setpoint=ph_setpoint,
264
+ timestamp=parse_datetime(timestamp_elem.get_text() if timestamp_elem else ''),
265
+ )
266
+
267
+ return reading
@@ -0,0 +1,90 @@
1
+ Metadata-Version: 2.4
2
+ Name: ips-api-client
3
+ Version: 0.1.0
4
+ Summary: Async Python client for IPS Controllers pool monitoring system
5
+ Home-page: https://github.com/stgarrity/ips-api-client
6
+ Author: Steve Garrity
7
+ Author-email: Steve Garrity <sgarrity@gmail.com>
8
+ License: MIT
9
+ Project-URL: Homepage, https://github.com/stgarrity/ips-api-client
10
+ Project-URL: Documentation, https://github.com/stgarrity/ips-api-client#readme
11
+ Project-URL: Repository, https://github.com/stgarrity/ips-api-client
12
+ Project-URL: Issues, https://github.com/stgarrity/ips-api-client/issues
13
+ Keywords: ips,pool,chemistry,monitoring,home-assistant
14
+ Classifier: Development Status :: 3 - Alpha
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.9
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Topic :: Home Automation
24
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
+ Requires-Python: >=3.9
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Requires-Dist: aiohttp>=3.8.0
29
+ Requires-Dist: beautifulsoup4>=4.11.0
30
+ Dynamic: author
31
+ Dynamic: home-page
32
+ Dynamic: license-file
33
+ Dynamic: requires-python
34
+
35
+ # IPS Controllers API Client
36
+
37
+ Python client library for the IPS Controllers pool monitoring system.
38
+
39
+ ## Installation
40
+
41
+ ```bash
42
+ pip install ips-api-client
43
+ ```
44
+
45
+ ## Usage
46
+
47
+ ```python
48
+ import asyncio
49
+ from ips_api import IPSClient
50
+
51
+ async def main():
52
+ async with IPSClient("your_email@example.com", "your_password") as client:
53
+ # Get all controllers
54
+ controllers = await client.get_controllers()
55
+
56
+ for controller in controllers:
57
+ print(f"Controller: {controller.name}")
58
+ print(f" pH: {controller.current_reading.ph}")
59
+ print(f" ORP: {controller.current_reading.orp} mV")
60
+ print(f" Status: {controller.status}")
61
+
62
+ # Get detailed reading
63
+ detail = await client.get_controller_detail(controller.controller_id)
64
+ print(f" pH Setpoint: {detail.ph_setpoint}")
65
+ print(f" pH State: {detail.ph_state}")
66
+
67
+ asyncio.run(main())
68
+ ```
69
+
70
+ ## Features
71
+
72
+ - Async/await support
73
+ - Automatic session management
74
+ - Support for multiple controllers
75
+ - Detailed pool chemistry readings (pH, ORP, temperature, chlorine)
76
+ - Controller status monitoring
77
+
78
+ ## Data Available
79
+
80
+ - pH level
81
+ - ORP (Oxidation-Reduction Potential) in mV
82
+ - Water temperature (when available)
83
+ - Chlorine PPM (when available)
84
+ - Controller status
85
+ - Last reading timestamp
86
+ - pH setpoint and state
87
+
88
+ ## License
89
+
90
+ MIT
@@ -0,0 +1,11 @@
1
+ ips_api/__init__.py,sha256=aeEKd4CGHL1ypLOFUFT2op1yrSQ8_UVLY6xRQ0r4aaE,481
2
+ ips_api/client.py,sha256=Wvgmew9yZcBVma7A1XcKcLJh45qfCGyisT94we_klPg,7517
3
+ ips_api/const.py,sha256=lrjsMBCtTnmgn9_LyKm6q6qGrbZLdeR3grqTn2MbPOM,654
4
+ ips_api/exceptions.py,sha256=YXUkJUFgs2qJqZ94pgk2FOzRu0ojy6kw6ia_CgUefAU,501
5
+ ips_api/models.py,sha256=c1qoVTVUitLrF-EFDliXjDMk6FXt2dgCm9I46Mcpr6A,1061
6
+ ips_api/parser.py,sha256=la5K3n4Wn4vurz4KXA9Ut9O7kuWoHGIYYK07ibruPak,8094
7
+ ips_api_client-0.1.0.dist-info/licenses/LICENSE,sha256=VT1yKHLweBlpBR7vx-Xyy3jzNcdhCsevHJG5db7ln5E,1070
8
+ ips_api_client-0.1.0.dist-info/METADATA,sha256=Ku3GGSv7rgDjkhtidefI7o6TNAN75ARZZcN3VkzksbI,2751
9
+ ips_api_client-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
10
+ ips_api_client-0.1.0.dist-info/top_level.txt,sha256=wDiAuWxLDsOdWyycllaqUv5MZRe5-uVbHp-TGeycfq4,8
11
+ ips_api_client-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Steve Garrity
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ ips_api