pylxpweb 0.1.0__py3-none-any.whl → 0.5.2__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 +47 -2
- pylxpweb/api_namespace.py +241 -0
- pylxpweb/cli/__init__.py +3 -0
- pylxpweb/cli/collect_device_data.py +874 -0
- pylxpweb/client.py +387 -26
- pylxpweb/constants/__init__.py +481 -0
- pylxpweb/constants/api.py +48 -0
- pylxpweb/constants/devices.py +98 -0
- pylxpweb/constants/locations.py +227 -0
- pylxpweb/{constants.py → constants/registers.py} +72 -238
- pylxpweb/constants/scaling.py +479 -0
- pylxpweb/devices/__init__.py +32 -0
- pylxpweb/devices/_firmware_update_mixin.py +504 -0
- pylxpweb/devices/_mid_runtime_properties.py +1427 -0
- pylxpweb/devices/base.py +122 -0
- pylxpweb/devices/battery.py +589 -0
- pylxpweb/devices/battery_bank.py +331 -0
- pylxpweb/devices/inverters/__init__.py +32 -0
- pylxpweb/devices/inverters/_features.py +378 -0
- pylxpweb/devices/inverters/_runtime_properties.py +596 -0
- pylxpweb/devices/inverters/base.py +2124 -0
- pylxpweb/devices/inverters/generic.py +192 -0
- pylxpweb/devices/inverters/hybrid.py +274 -0
- pylxpweb/devices/mid_device.py +183 -0
- pylxpweb/devices/models.py +126 -0
- pylxpweb/devices/parallel_group.py +364 -0
- pylxpweb/devices/station.py +908 -0
- pylxpweb/endpoints/control.py +980 -2
- pylxpweb/endpoints/devices.py +249 -16
- pylxpweb/endpoints/firmware.py +43 -10
- pylxpweb/endpoints/plants.py +15 -19
- pylxpweb/exceptions.py +4 -0
- pylxpweb/models.py +708 -41
- pylxpweb/transports/__init__.py +78 -0
- pylxpweb/transports/capabilities.py +101 -0
- pylxpweb/transports/data.py +501 -0
- pylxpweb/transports/exceptions.py +59 -0
- pylxpweb/transports/factory.py +119 -0
- pylxpweb/transports/http.py +329 -0
- pylxpweb/transports/modbus.py +617 -0
- pylxpweb/transports/protocol.py +217 -0
- {pylxpweb-0.1.0.dist-info → pylxpweb-0.5.2.dist-info}/METADATA +130 -85
- pylxpweb-0.5.2.dist-info/RECORD +52 -0
- {pylxpweb-0.1.0.dist-info → pylxpweb-0.5.2.dist-info}/WHEEL +1 -1
- pylxpweb-0.5.2.dist-info/entry_points.txt +3 -0
- pylxpweb-0.1.0.dist-info/RECORD +0 -19
pylxpweb/client.py
CHANGED
|
@@ -8,8 +8,15 @@ Key Features:
|
|
|
8
8
|
- Session management with auto-reauthentication
|
|
9
9
|
- Request caching with configurable TTL
|
|
10
10
|
- Exponential backoff for rate limiting
|
|
11
|
+
- Automatic retry for transient errors (DATAFRAME_TIMEOUT, BUSY, etc.)
|
|
11
12
|
- Support for injected aiohttp.ClientSession (Platinum tier requirement)
|
|
12
13
|
- Comprehensive error handling
|
|
14
|
+
|
|
15
|
+
Retry Behavior:
|
|
16
|
+
- Transient errors (hardware communication timeouts) are automatically retried
|
|
17
|
+
up to MAX_TRANSIENT_ERROR_RETRIES times with exponential backoff
|
|
18
|
+
- Non-transient errors (permissions, invalid parameters) fail immediately
|
|
19
|
+
- Session expiration triggers automatic re-authentication and retry
|
|
13
20
|
"""
|
|
14
21
|
|
|
15
22
|
from __future__ import annotations
|
|
@@ -24,6 +31,15 @@ from urllib.parse import urljoin
|
|
|
24
31
|
import aiohttp
|
|
25
32
|
from aiohttp import ClientTimeout
|
|
26
33
|
|
|
34
|
+
from .api_namespace import APINamespace
|
|
35
|
+
from .constants import (
|
|
36
|
+
BACKOFF_BASE_DELAY_SECONDS,
|
|
37
|
+
BACKOFF_MAX_DELAY_SECONDS,
|
|
38
|
+
HTTP_UNAUTHORIZED,
|
|
39
|
+
MAX_LOGIN_RETRIES,
|
|
40
|
+
MAX_TRANSIENT_ERROR_RETRIES,
|
|
41
|
+
TRANSIENT_ERROR_MESSAGES,
|
|
42
|
+
)
|
|
27
43
|
from .endpoints import (
|
|
28
44
|
AnalyticsEndpoints,
|
|
29
45
|
ControlEndpoints,
|
|
@@ -69,6 +85,7 @@ class LuxpowerClient:
|
|
|
69
85
|
verify_ssl: bool = True,
|
|
70
86
|
timeout: int = 30,
|
|
71
87
|
session: aiohttp.ClientSession | None = None,
|
|
88
|
+
iana_timezone: str | None = None,
|
|
72
89
|
) -> None:
|
|
73
90
|
"""Initialize the Luxpower API client.
|
|
74
91
|
|
|
@@ -77,16 +94,19 @@ class LuxpowerClient:
|
|
|
77
94
|
password: API password for authentication
|
|
78
95
|
base_url: Base URL for the API (default: EG4 Electronics endpoint)
|
|
79
96
|
verify_ssl: Whether to verify SSL certificates
|
|
80
|
-
timeout: Request timeout in seconds
|
|
81
|
-
session: Optional aiohttp
|
|
82
|
-
|
|
83
|
-
|
|
97
|
+
timeout: Request timeout in seconds (default: 30)
|
|
98
|
+
session: Optional aiohttp ClientSession for session injection
|
|
99
|
+
iana_timezone: Optional IANA timezone (e.g., "America/Los_Angeles")
|
|
100
|
+
for DST auto-detection. If not provided, DST auto-detection
|
|
101
|
+
will be disabled. This is required because the API doesn't
|
|
102
|
+
provide sufficient location data to reliably determine timezone.
|
|
84
103
|
"""
|
|
85
104
|
self.username = username
|
|
86
105
|
self.password = password
|
|
87
106
|
self.base_url = base_url.rstrip("/")
|
|
88
107
|
self.verify_ssl = verify_ssl
|
|
89
108
|
self.timeout = ClientTimeout(total=timeout)
|
|
109
|
+
self.iana_timezone = iana_timezone
|
|
90
110
|
|
|
91
111
|
# Session management
|
|
92
112
|
self._session: aiohttp.ClientSession | None = session
|
|
@@ -94,6 +114,8 @@ class LuxpowerClient:
|
|
|
94
114
|
self._session_id: str | None = None
|
|
95
115
|
self._session_expires: datetime | None = None
|
|
96
116
|
self._user_id: int | None = None
|
|
117
|
+
# Account level: "guest", "viewer", "operator", "owner", "installer"
|
|
118
|
+
self._account_level: str | None = None
|
|
97
119
|
|
|
98
120
|
# Response cache with TTL configuration
|
|
99
121
|
self._response_cache: dict[str, dict[str, Any]] = {}
|
|
@@ -109,15 +131,23 @@ class LuxpowerClient:
|
|
|
109
131
|
|
|
110
132
|
# Backoff configuration
|
|
111
133
|
self._backoff_config: dict[str, float] = {
|
|
112
|
-
"base_delay":
|
|
113
|
-
"max_delay":
|
|
134
|
+
"base_delay": BACKOFF_BASE_DELAY_SECONDS,
|
|
135
|
+
"max_delay": BACKOFF_MAX_DELAY_SECONDS,
|
|
114
136
|
"exponential_factor": 2.0,
|
|
115
137
|
"jitter": 0.1,
|
|
116
138
|
}
|
|
117
139
|
self._current_backoff_delay: float = 0.0
|
|
118
140
|
self._consecutive_errors: int = 0
|
|
119
141
|
|
|
120
|
-
#
|
|
142
|
+
# Hour tracking for automatic cache invalidation at boundaries
|
|
143
|
+
# Invalidates cache on first request after hour changes to ensure
|
|
144
|
+
# fresh data at date boundaries (especially midnight for daily energy values)
|
|
145
|
+
self._last_request_hour: int | None = None
|
|
146
|
+
|
|
147
|
+
# API namespace (new v0.2.0 interface)
|
|
148
|
+
self._api_namespace: APINamespace | None = None
|
|
149
|
+
|
|
150
|
+
# Endpoint modules (lazy-loaded) - kept for backward compatibility during transition
|
|
121
151
|
self._plants_endpoints: PlantEndpoints | None = None
|
|
122
152
|
self._devices_endpoints: DeviceEndpoints | None = None
|
|
123
153
|
self._control_endpoints: ControlEndpoints | None = None
|
|
@@ -165,7 +195,38 @@ class LuxpowerClient:
|
|
|
165
195
|
if self._session and not self._session.closed and self._owns_session:
|
|
166
196
|
await self._session.close()
|
|
167
197
|
|
|
168
|
-
#
|
|
198
|
+
# API Namespace (v0.2.0+)
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def api(self) -> APINamespace:
|
|
202
|
+
"""Access all API endpoints through the api namespace.
|
|
203
|
+
|
|
204
|
+
This is the recommended way to access API endpoints in v0.2.0+.
|
|
205
|
+
It provides a clear separation between:
|
|
206
|
+
- Low-level API calls: `client.api.plants.get_plants()`
|
|
207
|
+
- High-level object interface: `client.get_station(plant_id)` (coming in Phase 1)
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
APINamespace: The API namespace providing access to all endpoint groups.
|
|
211
|
+
|
|
212
|
+
Example:
|
|
213
|
+
```python
|
|
214
|
+
async with LuxpowerClient(username, password) as client:
|
|
215
|
+
# Access plants endpoint
|
|
216
|
+
plants = await client.api.plants.get_plants()
|
|
217
|
+
|
|
218
|
+
# Access devices endpoint
|
|
219
|
+
runtime = await client.api.devices.get_inverter_runtime(serial)
|
|
220
|
+
|
|
221
|
+
# Access control endpoint
|
|
222
|
+
await client.api.control.start_quick_charge(serial)
|
|
223
|
+
```
|
|
224
|
+
"""
|
|
225
|
+
if self._api_namespace is None:
|
|
226
|
+
self._api_namespace = APINamespace(self)
|
|
227
|
+
return self._api_namespace
|
|
228
|
+
|
|
229
|
+
# Endpoint Module Properties (Deprecated - use client.api.* instead)
|
|
169
230
|
|
|
170
231
|
@property
|
|
171
232
|
def plants(self) -> PlantEndpoints:
|
|
@@ -216,6 +277,28 @@ class LuxpowerClient:
|
|
|
216
277
|
self._firmware_endpoints = FirmwareEndpoints(self)
|
|
217
278
|
return self._firmware_endpoints
|
|
218
279
|
|
|
280
|
+
@property
|
|
281
|
+
def account_level(self) -> str | None:
|
|
282
|
+
"""Get detected account permission level.
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
Account level: "guest", "viewer", "operator", "owner", "installer",
|
|
286
|
+
or None if not detected.
|
|
287
|
+
|
|
288
|
+
Note:
|
|
289
|
+
This is automatically detected after login by checking device endUser fields.
|
|
290
|
+
- "guest": Read-only access, parameter read/write blocked
|
|
291
|
+
- "viewer"/"operator": Limited access, control operations may be blocked
|
|
292
|
+
- "owner"/"installer": Full access to all operations
|
|
293
|
+
|
|
294
|
+
Example:
|
|
295
|
+
>>> async with LuxpowerClient(username, password) as client:
|
|
296
|
+
>>> print(f"Account level: {client.account_level}")
|
|
297
|
+
>>> if client.account_level in ("guest", "viewer", "operator"):
|
|
298
|
+
>>> print("Control operations may be restricted")
|
|
299
|
+
"""
|
|
300
|
+
return self._account_level
|
|
301
|
+
|
|
219
302
|
async def _apply_backoff(self) -> None:
|
|
220
303
|
"""Apply exponential backoff delay before API requests."""
|
|
221
304
|
if self._current_backoff_delay > 0:
|
|
@@ -288,6 +371,83 @@ class LuxpowerClient:
|
|
|
288
371
|
return self._response_cache[cache_key].get("response")
|
|
289
372
|
return None
|
|
290
373
|
|
|
374
|
+
# ============================================================================
|
|
375
|
+
# Public Cache Management Methods
|
|
376
|
+
# ============================================================================
|
|
377
|
+
|
|
378
|
+
def clear_cache(self) -> None:
|
|
379
|
+
"""Clear all cached API responses.
|
|
380
|
+
|
|
381
|
+
This forces fresh data retrieval on the next API calls.
|
|
382
|
+
Useful when you know data has changed and need immediate updates.
|
|
383
|
+
|
|
384
|
+
Example:
|
|
385
|
+
>>> client.clear_cache()
|
|
386
|
+
>>> # Next API calls will fetch fresh data
|
|
387
|
+
"""
|
|
388
|
+
self._response_cache.clear()
|
|
389
|
+
_LOGGER.debug("Cache cleared (%d entries removed)", len(self._response_cache))
|
|
390
|
+
|
|
391
|
+
def invalidate_cache_for_device(self, serial_num: str) -> None:
|
|
392
|
+
"""Invalidate all cached responses for a specific device.
|
|
393
|
+
|
|
394
|
+
Args:
|
|
395
|
+
serial_num: Device serial number (inverter, battery, or GridBOSS)
|
|
396
|
+
|
|
397
|
+
Example:
|
|
398
|
+
>>> # After changing device settings
|
|
399
|
+
>>> client.invalidate_cache_for_device("1234567890")
|
|
400
|
+
>>> # Next calls for this device will fetch fresh data
|
|
401
|
+
"""
|
|
402
|
+
keys_to_remove = [key for key in self._response_cache if serial_num in key]
|
|
403
|
+
|
|
404
|
+
for key in keys_to_remove:
|
|
405
|
+
del self._response_cache[key]
|
|
406
|
+
|
|
407
|
+
_LOGGER.debug(
|
|
408
|
+
"Cache invalidated for device %s (%d entries removed)",
|
|
409
|
+
serial_num,
|
|
410
|
+
len(keys_to_remove),
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
@property
|
|
414
|
+
def cache_stats(self) -> dict[str, int | dict[str, int]]:
|
|
415
|
+
"""Get cache statistics.
|
|
416
|
+
|
|
417
|
+
Returns:
|
|
418
|
+
dict with statistics:
|
|
419
|
+
- total_entries: Number of cached responses
|
|
420
|
+
- endpoints: Dict of endpoint types to entry counts
|
|
421
|
+
|
|
422
|
+
Example:
|
|
423
|
+
>>> stats = client.cache_stats
|
|
424
|
+
>>> print(f"Cache size: {stats['total_entries']}")
|
|
425
|
+
>>> for endpoint, count in stats['endpoints'].items():
|
|
426
|
+
>>> print(f" {endpoint}: {count} entries")
|
|
427
|
+
"""
|
|
428
|
+
endpoints: dict[str, int] = {}
|
|
429
|
+
|
|
430
|
+
for key in self._response_cache:
|
|
431
|
+
# Extract endpoint type from cache key (format: "endpoint:params")
|
|
432
|
+
endpoint = key.split(":")[0] if ":" in key else key
|
|
433
|
+
endpoints[endpoint] = endpoints.get(endpoint, 0) + 1
|
|
434
|
+
|
|
435
|
+
return {
|
|
436
|
+
"total_entries": len(self._response_cache),
|
|
437
|
+
"endpoints": endpoints,
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
def _is_transient_error(self, error_msg: str) -> bool:
|
|
441
|
+
"""Check if an error message indicates a transient error.
|
|
442
|
+
|
|
443
|
+
Args:
|
|
444
|
+
error_msg: The error message to check
|
|
445
|
+
|
|
446
|
+
Returns:
|
|
447
|
+
bool: True if the error is transient and should be retried
|
|
448
|
+
"""
|
|
449
|
+
return any(transient in error_msg for transient in TRANSIENT_ERROR_MESSAGES)
|
|
450
|
+
|
|
291
451
|
async def _request(
|
|
292
452
|
self,
|
|
293
453
|
method: str,
|
|
@@ -296,15 +456,24 @@ class LuxpowerClient:
|
|
|
296
456
|
data: dict[str, Any] | None = None,
|
|
297
457
|
cache_key: str | None = None,
|
|
298
458
|
cache_endpoint: str | None = None,
|
|
459
|
+
_retry_count: int = 0,
|
|
299
460
|
) -> dict[str, Any]:
|
|
300
461
|
"""Make an HTTP request to the API.
|
|
301
462
|
|
|
463
|
+
Automatically invalidates cache on first request after hour boundary
|
|
464
|
+
to ensure fresh data at date rollovers (especially midnight for daily
|
|
465
|
+
energy values).
|
|
466
|
+
|
|
467
|
+
Automatically retries transient errors (e.g., DATAFRAME_TIMEOUT, BUSY)
|
|
468
|
+
with exponential backoff up to MAX_TRANSIENT_ERROR_RETRIES attempts.
|
|
469
|
+
|
|
302
470
|
Args:
|
|
303
471
|
method: HTTP method (GET, POST, etc.)
|
|
304
472
|
endpoint: API endpoint (will be joined with base_url)
|
|
305
473
|
data: Request data (will be form-encoded for POST)
|
|
306
474
|
cache_key: Optional cache key for response caching
|
|
307
475
|
cache_endpoint: Optional endpoint key for cache TTL lookup
|
|
476
|
+
_retry_count: Internal retry counter (do not set manually)
|
|
308
477
|
|
|
309
478
|
Returns:
|
|
310
479
|
dict: JSON response from the API
|
|
@@ -312,8 +481,21 @@ class LuxpowerClient:
|
|
|
312
481
|
Raises:
|
|
313
482
|
LuxpowerAuthError: If authentication fails
|
|
314
483
|
LuxpowerConnectionError: If connection fails
|
|
315
|
-
LuxpowerAPIError: If API returns an error
|
|
484
|
+
LuxpowerAPIError: If API returns an error (non-transient or max retries exceeded)
|
|
316
485
|
"""
|
|
486
|
+
# Auto-invalidate cache on first request after hour change
|
|
487
|
+
# This ensures fresh data after boundaries, especially midnight
|
|
488
|
+
current_hour = datetime.now().hour
|
|
489
|
+
if self._last_request_hour is not None and current_hour != self._last_request_hour:
|
|
490
|
+
_LOGGER.debug(
|
|
491
|
+
"Hour boundary crossed (hour %d → %d), invalidating all caches",
|
|
492
|
+
self._last_request_hour,
|
|
493
|
+
current_hour,
|
|
494
|
+
)
|
|
495
|
+
self.clear_cache()
|
|
496
|
+
|
|
497
|
+
self._last_request_hour = current_hour
|
|
498
|
+
|
|
317
499
|
# Check cache if enabled
|
|
318
500
|
if cache_key and cache_endpoint and self._is_cache_valid(cache_key, cache_endpoint):
|
|
319
501
|
cached = self._get_cached_response(cache_key)
|
|
@@ -343,6 +525,29 @@ class LuxpowerClient:
|
|
|
343
525
|
if not error_msg:
|
|
344
526
|
# No standard error message, show entire response
|
|
345
527
|
error_msg = f"No error message. Full response: {json_data}"
|
|
528
|
+
|
|
529
|
+
# Check if this is a transient error that should be retried
|
|
530
|
+
is_transient = self._is_transient_error(error_msg)
|
|
531
|
+
can_retry = _retry_count < MAX_TRANSIENT_ERROR_RETRIES
|
|
532
|
+
if is_transient and can_retry:
|
|
533
|
+
self._handle_request_error()
|
|
534
|
+
_LOGGER.warning(
|
|
535
|
+
"Transient API error '%s' (attempt %d/%d), retrying with backoff...",
|
|
536
|
+
error_msg,
|
|
537
|
+
_retry_count + 1,
|
|
538
|
+
MAX_TRANSIENT_ERROR_RETRIES,
|
|
539
|
+
)
|
|
540
|
+
# Retry with incremented counter
|
|
541
|
+
return await self._request(
|
|
542
|
+
method,
|
|
543
|
+
endpoint,
|
|
544
|
+
data=data,
|
|
545
|
+
cache_key=cache_key,
|
|
546
|
+
cache_endpoint=cache_endpoint,
|
|
547
|
+
_retry_count=_retry_count + 1,
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
# Non-transient error or max retries exceeded
|
|
346
551
|
raise LuxpowerAPIError(f"API error (HTTP {response.status}): {error_msg}")
|
|
347
552
|
|
|
348
553
|
# Cache successful response
|
|
@@ -352,14 +557,50 @@ class LuxpowerClient:
|
|
|
352
557
|
self._handle_request_success()
|
|
353
558
|
return json_data
|
|
354
559
|
|
|
560
|
+
except aiohttp.ContentTypeError as err:
|
|
561
|
+
# Session expired and API returned HTML login page instead of JSON
|
|
562
|
+
self._handle_request_error(err)
|
|
563
|
+
_LOGGER.warning(
|
|
564
|
+
"Got HTML response instead of JSON (session expired), attempting to re-authenticate"
|
|
565
|
+
)
|
|
566
|
+
try:
|
|
567
|
+
await self.login()
|
|
568
|
+
_LOGGER.debug("Re-authentication successful, retrying request")
|
|
569
|
+
# Retry the request with the new session
|
|
570
|
+
return await self._request(
|
|
571
|
+
method,
|
|
572
|
+
endpoint,
|
|
573
|
+
data=data,
|
|
574
|
+
cache_key=cache_key,
|
|
575
|
+
cache_endpoint=cache_endpoint,
|
|
576
|
+
_retry_count=_retry_count, # Preserve retry count
|
|
577
|
+
)
|
|
578
|
+
except LuxpowerAuthError:
|
|
579
|
+
# True authentication failure (wrong credentials, account locked)
|
|
580
|
+
_LOGGER.error("Re-authentication failed: invalid credentials")
|
|
581
|
+
raise
|
|
582
|
+
except LuxpowerConnectionError as login_err:
|
|
583
|
+
# Transient connection issue during re-auth - don't treat as auth failure
|
|
584
|
+
# This allows Home Assistant to retry automatically instead of requiring
|
|
585
|
+
# manual re-authentication (fixes issue #70)
|
|
586
|
+
_LOGGER.warning("Re-authentication failed due to connection issue: %s", login_err)
|
|
587
|
+
raise
|
|
588
|
+
except Exception as login_err:
|
|
589
|
+
# Other unexpected errors during re-auth - treat as connection issue
|
|
590
|
+
# to allow automatic retry rather than requiring manual intervention
|
|
591
|
+
_LOGGER.error("Re-authentication failed unexpectedly: %s", login_err)
|
|
592
|
+
raise LuxpowerConnectionError(
|
|
593
|
+
f"Re-authentication failed: {login_err}"
|
|
594
|
+
) from login_err
|
|
595
|
+
|
|
355
596
|
except aiohttp.ClientResponseError as err:
|
|
356
597
|
self._handle_request_error(err)
|
|
357
|
-
if err.status ==
|
|
598
|
+
if err.status == HTTP_UNAUTHORIZED:
|
|
358
599
|
# Session expired - try to re-authenticate once
|
|
359
600
|
_LOGGER.warning("Got 401 Unauthorized, attempting to re-authenticate")
|
|
360
601
|
try:
|
|
361
602
|
await self.login()
|
|
362
|
-
_LOGGER.
|
|
603
|
+
_LOGGER.debug("Re-authentication successful, retrying request")
|
|
363
604
|
# Retry the request with the new session
|
|
364
605
|
return await self._request(
|
|
365
606
|
method,
|
|
@@ -367,12 +608,33 @@ class LuxpowerClient:
|
|
|
367
608
|
data=data,
|
|
368
609
|
cache_key=cache_key,
|
|
369
610
|
cache_endpoint=cache_endpoint,
|
|
611
|
+
_retry_count=_retry_count, # Preserve retry count
|
|
612
|
+
)
|
|
613
|
+
except LuxpowerAuthError:
|
|
614
|
+
# True authentication failure (wrong credentials, account locked)
|
|
615
|
+
_LOGGER.error("Re-authentication failed: invalid credentials")
|
|
616
|
+
raise
|
|
617
|
+
except LuxpowerConnectionError as login_err:
|
|
618
|
+
# Transient connection issue during re-auth - don't treat as auth failure
|
|
619
|
+
# This allows Home Assistant to retry automatically instead of requiring
|
|
620
|
+
# manual re-authentication (fixes issue #70)
|
|
621
|
+
_LOGGER.warning(
|
|
622
|
+
"Re-authentication failed due to connection issue: %s", login_err
|
|
370
623
|
)
|
|
624
|
+
raise
|
|
371
625
|
except Exception as login_err:
|
|
372
|
-
|
|
373
|
-
|
|
626
|
+
# Other unexpected errors during re-auth - treat as connection issue
|
|
627
|
+
# to allow automatic retry rather than requiring manual intervention
|
|
628
|
+
_LOGGER.error("Re-authentication failed unexpectedly: %s", login_err)
|
|
629
|
+
raise LuxpowerConnectionError(
|
|
630
|
+
f"Re-authentication failed: {login_err}"
|
|
631
|
+
) from login_err
|
|
374
632
|
raise LuxpowerAPIError(f"HTTP {err.status}: {err.message}") from err
|
|
375
633
|
|
|
634
|
+
except LuxpowerAPIError:
|
|
635
|
+
# Re-raise our own exceptions (from transient error handling, etc)
|
|
636
|
+
raise
|
|
637
|
+
|
|
376
638
|
except aiohttp.ClientError as err:
|
|
377
639
|
self._handle_request_error(err)
|
|
378
640
|
raise LuxpowerConnectionError(f"Connection error: {err}") from err
|
|
@@ -383,16 +645,30 @@ class LuxpowerClient:
|
|
|
383
645
|
|
|
384
646
|
# Authentication
|
|
385
647
|
|
|
386
|
-
async def login(self) -> LoginResponse:
|
|
648
|
+
async def login(self, _retry_count: int = 0) -> LoginResponse:
|
|
387
649
|
"""Authenticate with the API and establish a session.
|
|
388
650
|
|
|
651
|
+
This method includes automatic retry logic for transient failures
|
|
652
|
+
(network issues, temporary server errors) with exponential backoff.
|
|
653
|
+
This allows recovery from temporary issues without requiring manual
|
|
654
|
+
user intervention (fixes issue #70).
|
|
655
|
+
|
|
656
|
+
Args:
|
|
657
|
+
_retry_count: Internal retry counter (do not set manually)
|
|
658
|
+
|
|
389
659
|
Returns:
|
|
390
660
|
LoginResponse: Login response with user and plant information
|
|
391
661
|
|
|
392
662
|
Raises:
|
|
393
|
-
LuxpowerAuthError: If authentication fails
|
|
663
|
+
LuxpowerAuthError: If authentication fails due to invalid credentials
|
|
664
|
+
LuxpowerConnectionError: If connection fails after all retries
|
|
394
665
|
"""
|
|
395
|
-
_LOGGER.
|
|
666
|
+
_LOGGER.debug(
|
|
667
|
+
"Logging in as %s (attempt %d/%d)",
|
|
668
|
+
self.username,
|
|
669
|
+
_retry_count + 1,
|
|
670
|
+
MAX_LOGIN_RETRIES,
|
|
671
|
+
)
|
|
396
672
|
|
|
397
673
|
data = {
|
|
398
674
|
"account": self.username,
|
|
@@ -400,18 +676,103 @@ class LuxpowerClient:
|
|
|
400
676
|
"language": "ENGLISH",
|
|
401
677
|
}
|
|
402
678
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
679
|
+
try:
|
|
680
|
+
response = await self._request("POST", "/WManage/api/login", data=data)
|
|
681
|
+
login_data = LoginResponse.model_validate(response)
|
|
682
|
+
|
|
683
|
+
# Store session info (session cookie is automatically handled by aiohttp)
|
|
684
|
+
self._session_expires = datetime.now() + timedelta(hours=2)
|
|
685
|
+
self._user_id = login_data.userId
|
|
686
|
+
_LOGGER.debug("Login successful, session expires at %s", self._session_expires)
|
|
687
|
+
|
|
688
|
+
# Detect account level from endUser field
|
|
689
|
+
await self._detect_account_level()
|
|
690
|
+
|
|
691
|
+
return login_data
|
|
692
|
+
|
|
693
|
+
except LuxpowerAuthError:
|
|
694
|
+
# True authentication failure (wrong password, account locked)
|
|
695
|
+
# Don't retry - re-raise immediately
|
|
696
|
+
raise
|
|
697
|
+
|
|
698
|
+
except LuxpowerAPIError:
|
|
699
|
+
# API-level error (not transient) - don't retry
|
|
700
|
+
raise
|
|
701
|
+
|
|
702
|
+
except LuxpowerConnectionError as err:
|
|
703
|
+
# Transient connection issue - retry with backoff
|
|
704
|
+
if _retry_count < MAX_LOGIN_RETRIES - 1:
|
|
705
|
+
delay = self._backoff_config["base_delay"] * (2**_retry_count)
|
|
706
|
+
_LOGGER.warning(
|
|
707
|
+
"Login failed due to connection error (attempt %d/%d): %s. "
|
|
708
|
+
"Retrying in %.1f seconds...",
|
|
709
|
+
_retry_count + 1,
|
|
710
|
+
MAX_LOGIN_RETRIES,
|
|
711
|
+
err,
|
|
712
|
+
delay,
|
|
713
|
+
)
|
|
714
|
+
await asyncio.sleep(delay)
|
|
715
|
+
return await self.login(_retry_count=_retry_count + 1)
|
|
716
|
+
# Max retries exceeded
|
|
717
|
+
_LOGGER.error(
|
|
718
|
+
"Login failed after %d attempts due to connection errors: %s",
|
|
719
|
+
MAX_LOGIN_RETRIES,
|
|
720
|
+
err,
|
|
721
|
+
)
|
|
722
|
+
raise
|
|
412
723
|
|
|
413
724
|
async def _ensure_authenticated(self) -> None:
|
|
414
725
|
"""Ensure we have a valid session, re-authenticating if needed."""
|
|
415
726
|
if not self._session_expires or datetime.now() >= self._session_expires:
|
|
416
|
-
_LOGGER.
|
|
727
|
+
_LOGGER.debug("Session expired or missing, re-authenticating")
|
|
417
728
|
await self.login()
|
|
729
|
+
|
|
730
|
+
async def _detect_account_level(self) -> None:
|
|
731
|
+
"""Detect account permission level from device list endUser field.
|
|
732
|
+
|
|
733
|
+
This checks the endUser field from the first available device to determine
|
|
734
|
+
the account type. The detection is done once after login and cached.
|
|
735
|
+
|
|
736
|
+
Account levels:
|
|
737
|
+
- "guest": Read-only access, parameter read/write blocked
|
|
738
|
+
- "viewer" or username: Limited access, control operations may be blocked
|
|
739
|
+
- "installer": Full access to all operations
|
|
740
|
+
|
|
741
|
+
Note:
|
|
742
|
+
If endUser is a username (not "guest"), we classify it as "viewer/operator"
|
|
743
|
+
level since it's neither guest nor installer. This may indicate limited
|
|
744
|
+
control permissions.
|
|
745
|
+
"""
|
|
746
|
+
if self._account_level is not None:
|
|
747
|
+
return # Already detected
|
|
748
|
+
|
|
749
|
+
try:
|
|
750
|
+
# Get plants to find a valid plant ID
|
|
751
|
+
plants_response = await self.api.plants.get_plants()
|
|
752
|
+
if not plants_response.rows:
|
|
753
|
+
_LOGGER.warning("No plants found, cannot detect account level")
|
|
754
|
+
return
|
|
755
|
+
|
|
756
|
+
# Get devices for first plant
|
|
757
|
+
devices_response = await self.api.devices.get_devices(plants_response.rows[0].plantId)
|
|
758
|
+
if not devices_response.rows:
|
|
759
|
+
_LOGGER.warning("No devices found, cannot detect account level")
|
|
760
|
+
return
|
|
761
|
+
|
|
762
|
+
# Check endUser field from first device
|
|
763
|
+
end_user = devices_response.rows[0].endUser
|
|
764
|
+
if end_user == "guest":
|
|
765
|
+
self._account_level = "guest"
|
|
766
|
+
elif end_user and ("installer" in end_user.lower()):
|
|
767
|
+
self._account_level = "installer"
|
|
768
|
+
elif end_user and end_user != "":
|
|
769
|
+
# Has endUser value but not guest or installer - likely viewer/operator
|
|
770
|
+
self._account_level = "viewer"
|
|
771
|
+
else:
|
|
772
|
+
# No endUser field or empty - assume owner (backward compatibility)
|
|
773
|
+
self._account_level = "owner"
|
|
774
|
+
|
|
775
|
+
_LOGGER.debug("Detected account level: %s (endUser=%s)", self._account_level, end_user)
|
|
776
|
+
|
|
777
|
+
except Exception as err:
|
|
778
|
+
_LOGGER.warning("Failed to detect account level: %s", err)
|