pylxpweb 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.
pylxpweb/__init__.py ADDED
@@ -0,0 +1,39 @@
1
+ """Python client library for Luxpower/EG4 inverter web monitoring API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .client import LuxpowerClient
6
+ from .endpoints import (
7
+ AnalyticsEndpoints,
8
+ ControlEndpoints,
9
+ DeviceEndpoints,
10
+ ExportEndpoints,
11
+ FirmwareEndpoints,
12
+ ForecastingEndpoints,
13
+ PlantEndpoints,
14
+ )
15
+ from .exceptions import (
16
+ LuxpowerAPIError,
17
+ LuxpowerAuthError,
18
+ LuxpowerConnectionError,
19
+ LuxpowerDeviceError,
20
+ LuxpowerError,
21
+ )
22
+
23
+ __version__ = "0.1.0"
24
+ __all__ = [
25
+ "LuxpowerClient",
26
+ "LuxpowerError",
27
+ "LuxpowerAPIError",
28
+ "LuxpowerAuthError",
29
+ "LuxpowerConnectionError",
30
+ "LuxpowerDeviceError",
31
+ # Endpoint modules
32
+ "PlantEndpoints",
33
+ "DeviceEndpoints",
34
+ "ControlEndpoints",
35
+ "AnalyticsEndpoints",
36
+ "ForecastingEndpoints",
37
+ "ExportEndpoints",
38
+ "FirmwareEndpoints",
39
+ ]
pylxpweb/client.py ADDED
@@ -0,0 +1,417 @@
1
+ """Luxpower/EG4 Inverter API Client.
2
+
3
+ This module provides a comprehensive async client for interacting with the
4
+ Luxpower/EG4 inverter web monitoring API.
5
+
6
+ Key Features:
7
+ - Async/await support with aiohttp
8
+ - Session management with auto-reauthentication
9
+ - Request caching with configurable TTL
10
+ - Exponential backoff for rate limiting
11
+ - Support for injected aiohttp.ClientSession (Platinum tier requirement)
12
+ - Comprehensive error handling
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import logging
19
+ import random
20
+ from datetime import datetime, timedelta
21
+ from typing import Any
22
+ from urllib.parse import urljoin
23
+
24
+ import aiohttp
25
+ from aiohttp import ClientTimeout
26
+
27
+ from .endpoints import (
28
+ AnalyticsEndpoints,
29
+ ControlEndpoints,
30
+ DeviceEndpoints,
31
+ ExportEndpoints,
32
+ FirmwareEndpoints,
33
+ ForecastingEndpoints,
34
+ PlantEndpoints,
35
+ )
36
+ from .exceptions import (
37
+ LuxpowerAPIError,
38
+ LuxpowerAuthError,
39
+ LuxpowerConnectionError,
40
+ )
41
+ from .models import LoginResponse
42
+
43
+ _LOGGER = logging.getLogger(__name__)
44
+
45
+
46
+ class LuxpowerClient:
47
+ """Luxpower/EG4 Inverter API Client.
48
+
49
+ This client provides async access to the Luxpower/EG4 inverter web monitoring API.
50
+
51
+ Example:
52
+ ```python
53
+ async with LuxpowerClient(username, password) as client:
54
+ plants = await client.get_plants()
55
+ for plant in plants.rows:
56
+ devices = await client.get_devices(plant.plantId)
57
+ for device in devices.rows:
58
+ runtime = await client.get_inverter_runtime(device.serialNum)
59
+ print(f"Power: {runtime.ppv}W, SOC: {runtime.soc}%")
60
+ ```
61
+ """
62
+
63
+ def __init__(
64
+ self,
65
+ username: str,
66
+ password: str,
67
+ *,
68
+ base_url: str = "https://monitor.eg4electronics.com",
69
+ verify_ssl: bool = True,
70
+ timeout: int = 30,
71
+ session: aiohttp.ClientSession | None = None,
72
+ ) -> None:
73
+ """Initialize the Luxpower API client.
74
+
75
+ Args:
76
+ username: API username for authentication
77
+ password: API password for authentication
78
+ base_url: Base URL for the API (default: EG4 Electronics endpoint)
79
+ verify_ssl: Whether to verify SSL certificates
80
+ timeout: Request timeout in seconds
81
+ session: Optional aiohttp.ClientSession to use for requests.
82
+ If not provided, a new session will be created.
83
+ Platinum tier requirement: Support websession injection.
84
+ """
85
+ self.username = username
86
+ self.password = password
87
+ self.base_url = base_url.rstrip("/")
88
+ self.verify_ssl = verify_ssl
89
+ self.timeout = ClientTimeout(total=timeout)
90
+
91
+ # Session management
92
+ self._session: aiohttp.ClientSession | None = session
93
+ self._owns_session: bool = session is None
94
+ self._session_id: str | None = None
95
+ self._session_expires: datetime | None = None
96
+ self._user_id: int | None = None
97
+
98
+ # Response cache with TTL configuration
99
+ self._response_cache: dict[str, dict[str, Any]] = {}
100
+ self._cache_ttl_config: dict[str, timedelta] = {
101
+ "device_discovery": timedelta(minutes=15),
102
+ "battery_info": timedelta(minutes=5),
103
+ "parameter_read": timedelta(minutes=2),
104
+ "quick_charge_status": timedelta(minutes=1),
105
+ "inverter_runtime": timedelta(seconds=20),
106
+ "inverter_energy": timedelta(seconds=20),
107
+ "midbox_runtime": timedelta(seconds=20),
108
+ }
109
+
110
+ # Backoff configuration
111
+ self._backoff_config: dict[str, float] = {
112
+ "base_delay": 1.0,
113
+ "max_delay": 60.0,
114
+ "exponential_factor": 2.0,
115
+ "jitter": 0.1,
116
+ }
117
+ self._current_backoff_delay: float = 0.0
118
+ self._consecutive_errors: int = 0
119
+
120
+ # Endpoint modules (lazy-loaded)
121
+ self._plants_endpoints: PlantEndpoints | None = None
122
+ self._devices_endpoints: DeviceEndpoints | None = None
123
+ self._control_endpoints: ControlEndpoints | None = None
124
+ self._analytics_endpoints: AnalyticsEndpoints | None = None
125
+ self._forecasting_endpoints: ForecastingEndpoints | None = None
126
+ self._export_endpoints: ExportEndpoints | None = None
127
+ self._firmware_endpoints: FirmwareEndpoints | None = None
128
+
129
+ async def __aenter__(self) -> LuxpowerClient:
130
+ """Async context manager entry."""
131
+ await self.login()
132
+ return self
133
+
134
+ async def __aexit__(
135
+ self,
136
+ exc_type: type[BaseException] | None,
137
+ exc_val: BaseException | None,
138
+ exc_tb: Any,
139
+ ) -> None:
140
+ """Async context manager exit."""
141
+ await self.close()
142
+
143
+ async def _get_session(self) -> aiohttp.ClientSession:
144
+ """Get or create aiohttp session.
145
+
146
+ Returns:
147
+ aiohttp.ClientSession: The session to use for requests.
148
+ """
149
+ if self._session is not None and not self._owns_session:
150
+ return self._session
151
+
152
+ if self._session is None or self._session.closed:
153
+ connector = aiohttp.TCPConnector(ssl=self.verify_ssl)
154
+ self._session = aiohttp.ClientSession(connector=connector, timeout=self.timeout)
155
+ self._owns_session = True
156
+
157
+ return self._session
158
+
159
+ async def close(self) -> None:
160
+ """Close the session if we own it.
161
+
162
+ Only closes the session if it was created by this client,
163
+ not if it was injected.
164
+ """
165
+ if self._session and not self._session.closed and self._owns_session:
166
+ await self._session.close()
167
+
168
+ # Endpoint Module Properties
169
+
170
+ @property
171
+ def plants(self) -> PlantEndpoints:
172
+ """Access plant/station management endpoints."""
173
+ if self._plants_endpoints is None:
174
+ self._plants_endpoints = PlantEndpoints(self)
175
+ return self._plants_endpoints
176
+
177
+ @property
178
+ def devices(self) -> DeviceEndpoints:
179
+ """Access device discovery and runtime data endpoints."""
180
+ if self._devices_endpoints is None:
181
+ self._devices_endpoints = DeviceEndpoints(self)
182
+ return self._devices_endpoints
183
+
184
+ @property
185
+ def control(self) -> ControlEndpoints:
186
+ """Access parameter control and device function endpoints."""
187
+ if self._control_endpoints is None:
188
+ self._control_endpoints = ControlEndpoints(self)
189
+ return self._control_endpoints
190
+
191
+ @property
192
+ def analytics(self) -> AnalyticsEndpoints:
193
+ """Access analytics, charts, and event log endpoints."""
194
+ if self._analytics_endpoints is None:
195
+ self._analytics_endpoints = AnalyticsEndpoints(self)
196
+ return self._analytics_endpoints
197
+
198
+ @property
199
+ def forecasting(self) -> ForecastingEndpoints:
200
+ """Access solar and weather forecasting endpoints."""
201
+ if self._forecasting_endpoints is None:
202
+ self._forecasting_endpoints = ForecastingEndpoints(self)
203
+ return self._forecasting_endpoints
204
+
205
+ @property
206
+ def export(self) -> ExportEndpoints:
207
+ """Access data export endpoints."""
208
+ if self._export_endpoints is None:
209
+ self._export_endpoints = ExportEndpoints(self)
210
+ return self._export_endpoints
211
+
212
+ @property
213
+ def firmware(self) -> FirmwareEndpoints:
214
+ """Access firmware update endpoints."""
215
+ if self._firmware_endpoints is None:
216
+ self._firmware_endpoints = FirmwareEndpoints(self)
217
+ return self._firmware_endpoints
218
+
219
+ async def _apply_backoff(self) -> None:
220
+ """Apply exponential backoff delay before API requests."""
221
+ if self._current_backoff_delay > 0:
222
+ jitter = random.uniform(0, self._backoff_config["jitter"])
223
+ delay = self._current_backoff_delay + jitter
224
+ _LOGGER.debug("Applying backoff delay: %.2f seconds", delay)
225
+ await asyncio.sleep(delay)
226
+
227
+ def _handle_request_success(self) -> None:
228
+ """Reset backoff on successful request."""
229
+ if self._consecutive_errors > 0:
230
+ _LOGGER.debug(
231
+ "Request successful, resetting backoff after %d errors",
232
+ self._consecutive_errors,
233
+ )
234
+ self._consecutive_errors = 0
235
+ self._current_backoff_delay = 0.0
236
+
237
+ def _handle_request_error(self, error: Exception | None = None) -> None:
238
+ """Increase backoff delay on request error.
239
+
240
+ Args:
241
+ error: The exception that caused the error (for logging)
242
+ """
243
+ self._consecutive_errors += 1
244
+ base_delay = self._backoff_config["base_delay"]
245
+ max_delay = self._backoff_config["max_delay"]
246
+ factor = self._backoff_config["exponential_factor"]
247
+
248
+ self._current_backoff_delay = min(
249
+ base_delay * (factor ** (self._consecutive_errors - 1)), max_delay
250
+ )
251
+
252
+ error_msg = f": {error}" if error else ""
253
+ _LOGGER.warning(
254
+ "API request error #%d%s, next backoff delay: %.2f seconds",
255
+ self._consecutive_errors,
256
+ error_msg,
257
+ self._current_backoff_delay,
258
+ )
259
+
260
+ def _get_cache_key(self, endpoint_key: str, **params: Any) -> str:
261
+ """Generate a cache key for an endpoint and parameters."""
262
+ param_str = "&".join(f"{k}={v}" for k, v in sorted(params.items()))
263
+ return f"{endpoint_key}:{param_str}"
264
+
265
+ def _is_cache_valid(self, cache_key: str, endpoint_key: str) -> bool:
266
+ """Check if cached response is still valid."""
267
+ if cache_key not in self._response_cache:
268
+ return False
269
+
270
+ cache_entry = self._response_cache[cache_key]
271
+ cache_time = cache_entry.get("timestamp")
272
+ if not isinstance(cache_time, datetime):
273
+ return False
274
+
275
+ ttl = self._cache_ttl_config.get(endpoint_key, timedelta(seconds=30))
276
+ return datetime.now() < cache_time + ttl
277
+
278
+ def _cache_response(self, cache_key: str, response: dict[str, Any]) -> None:
279
+ """Cache a response with timestamp."""
280
+ self._response_cache[cache_key] = {
281
+ "timestamp": datetime.now(),
282
+ "response": response,
283
+ }
284
+
285
+ def _get_cached_response(self, cache_key: str) -> dict[str, Any] | None:
286
+ """Get cached response if valid."""
287
+ if cache_key in self._response_cache:
288
+ return self._response_cache[cache_key].get("response")
289
+ return None
290
+
291
+ async def _request(
292
+ self,
293
+ method: str,
294
+ endpoint: str,
295
+ *,
296
+ data: dict[str, Any] | None = None,
297
+ cache_key: str | None = None,
298
+ cache_endpoint: str | None = None,
299
+ ) -> dict[str, Any]:
300
+ """Make an HTTP request to the API.
301
+
302
+ Args:
303
+ method: HTTP method (GET, POST, etc.)
304
+ endpoint: API endpoint (will be joined with base_url)
305
+ data: Request data (will be form-encoded for POST)
306
+ cache_key: Optional cache key for response caching
307
+ cache_endpoint: Optional endpoint key for cache TTL lookup
308
+
309
+ Returns:
310
+ dict: JSON response from the API
311
+
312
+ Raises:
313
+ LuxpowerAuthError: If authentication fails
314
+ LuxpowerConnectionError: If connection fails
315
+ LuxpowerAPIError: If API returns an error
316
+ """
317
+ # Check cache if enabled
318
+ if cache_key and cache_endpoint and self._is_cache_valid(cache_key, cache_endpoint):
319
+ cached = self._get_cached_response(cache_key)
320
+ if cached:
321
+ _LOGGER.debug("Using cached response for %s", cache_key)
322
+ return cached
323
+
324
+ # Apply backoff if needed
325
+ await self._apply_backoff()
326
+
327
+ session = await self._get_session()
328
+ url = urljoin(self.base_url, endpoint)
329
+
330
+ headers = {
331
+ "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
332
+ "Accept": "application/json",
333
+ }
334
+
335
+ try:
336
+ async with session.request(method, url, data=data, headers=headers) as response:
337
+ response.raise_for_status()
338
+ json_data: dict[str, Any] = await response.json()
339
+
340
+ # Handle API-level errors (HTTP 200 but success=false in JSON)
341
+ if isinstance(json_data, dict) and not json_data.get("success", True):
342
+ error_msg = json_data.get("message") or json_data.get("msg")
343
+ if not error_msg:
344
+ # No standard error message, show entire response
345
+ error_msg = f"No error message. Full response: {json_data}"
346
+ raise LuxpowerAPIError(f"API error (HTTP {response.status}): {error_msg}")
347
+
348
+ # Cache successful response
349
+ if cache_key and cache_endpoint:
350
+ self._cache_response(cache_key, json_data)
351
+
352
+ self._handle_request_success()
353
+ return json_data
354
+
355
+ except aiohttp.ClientResponseError as err:
356
+ self._handle_request_error(err)
357
+ if err.status == 401:
358
+ # Session expired - try to re-authenticate once
359
+ _LOGGER.warning("Got 401 Unauthorized, attempting to re-authenticate")
360
+ try:
361
+ await self.login()
362
+ _LOGGER.info("Re-authentication successful, retrying request")
363
+ # Retry the request with the new session
364
+ return await self._request(
365
+ method,
366
+ endpoint,
367
+ data=data,
368
+ cache_key=cache_key,
369
+ cache_endpoint=cache_endpoint,
370
+ )
371
+ except Exception as login_err:
372
+ _LOGGER.error("Re-authentication failed: %s", login_err)
373
+ raise LuxpowerAuthError("Authentication failed") from err
374
+ raise LuxpowerAPIError(f"HTTP {err.status}: {err.message}") from err
375
+
376
+ except aiohttp.ClientError as err:
377
+ self._handle_request_error(err)
378
+ raise LuxpowerConnectionError(f"Connection error: {err}") from err
379
+
380
+ except Exception as err:
381
+ self._handle_request_error(err)
382
+ raise LuxpowerAPIError(f"Unexpected error: {err}") from err
383
+
384
+ # Authentication
385
+
386
+ async def login(self) -> LoginResponse:
387
+ """Authenticate with the API and establish a session.
388
+
389
+ Returns:
390
+ LoginResponse: Login response with user and plant information
391
+
392
+ Raises:
393
+ LuxpowerAuthError: If authentication fails
394
+ """
395
+ _LOGGER.info("Logging in as %s", self.username)
396
+
397
+ data = {
398
+ "account": self.username,
399
+ "password": self.password,
400
+ "language": "ENGLISH",
401
+ }
402
+
403
+ response = await self._request("POST", "/WManage/api/login", data=data)
404
+ login_data = LoginResponse.model_validate(response)
405
+
406
+ # Store session info (session cookie is automatically handled by aiohttp)
407
+ self._session_expires = datetime.now() + timedelta(hours=2)
408
+ self._user_id = login_data.userId
409
+ _LOGGER.info("Login successful, session expires at %s", self._session_expires)
410
+
411
+ return login_data
412
+
413
+ async def _ensure_authenticated(self) -> None:
414
+ """Ensure we have a valid session, re-authenticating if needed."""
415
+ if not self._session_expires or datetime.now() >= self._session_expires:
416
+ _LOGGER.info("Session expired or missing, re-authenticating")
417
+ await self.login()