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 +23 -0
- ips_api/client.py +238 -0
- ips_api/const.py +25 -0
- ips_api/exceptions.py +26 -0
- ips_api/models.py +41 -0
- ips_api/parser.py +267 -0
- ips_api_client-0.1.0.dist-info/METADATA +90 -0
- ips_api_client-0.1.0.dist-info/RECORD +11 -0
- ips_api_client-0.1.0.dist-info/WHEEL +5 -0
- ips_api_client-0.1.0.dist-info/licenses/LICENSE +21 -0
- ips_api_client-0.1.0.dist-info/top_level.txt +1 -0
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,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
|