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 +39 -0
- pylxpweb/client.py +417 -0
- pylxpweb/constants.py +1183 -0
- pylxpweb/endpoints/__init__.py +27 -0
- pylxpweb/endpoints/analytics.py +446 -0
- pylxpweb/endpoints/base.py +43 -0
- pylxpweb/endpoints/control.py +306 -0
- pylxpweb/endpoints/devices.py +250 -0
- pylxpweb/endpoints/export.py +86 -0
- pylxpweb/endpoints/firmware.py +235 -0
- pylxpweb/endpoints/forecasting.py +109 -0
- pylxpweb/endpoints/plants.py +470 -0
- pylxpweb/exceptions.py +23 -0
- pylxpweb/models.py +765 -0
- pylxpweb/py.typed +0 -0
- pylxpweb/registers.py +511 -0
- pylxpweb-0.1.0.dist-info/METADATA +433 -0
- pylxpweb-0.1.0.dist-info/RECORD +19 -0
- pylxpweb-0.1.0.dist-info/WHEEL +4 -0
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()
|