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.
Files changed (46) hide show
  1. pylxpweb/__init__.py +47 -2
  2. pylxpweb/api_namespace.py +241 -0
  3. pylxpweb/cli/__init__.py +3 -0
  4. pylxpweb/cli/collect_device_data.py +874 -0
  5. pylxpweb/client.py +387 -26
  6. pylxpweb/constants/__init__.py +481 -0
  7. pylxpweb/constants/api.py +48 -0
  8. pylxpweb/constants/devices.py +98 -0
  9. pylxpweb/constants/locations.py +227 -0
  10. pylxpweb/{constants.py → constants/registers.py} +72 -238
  11. pylxpweb/constants/scaling.py +479 -0
  12. pylxpweb/devices/__init__.py +32 -0
  13. pylxpweb/devices/_firmware_update_mixin.py +504 -0
  14. pylxpweb/devices/_mid_runtime_properties.py +1427 -0
  15. pylxpweb/devices/base.py +122 -0
  16. pylxpweb/devices/battery.py +589 -0
  17. pylxpweb/devices/battery_bank.py +331 -0
  18. pylxpweb/devices/inverters/__init__.py +32 -0
  19. pylxpweb/devices/inverters/_features.py +378 -0
  20. pylxpweb/devices/inverters/_runtime_properties.py +596 -0
  21. pylxpweb/devices/inverters/base.py +2124 -0
  22. pylxpweb/devices/inverters/generic.py +192 -0
  23. pylxpweb/devices/inverters/hybrid.py +274 -0
  24. pylxpweb/devices/mid_device.py +183 -0
  25. pylxpweb/devices/models.py +126 -0
  26. pylxpweb/devices/parallel_group.py +364 -0
  27. pylxpweb/devices/station.py +908 -0
  28. pylxpweb/endpoints/control.py +980 -2
  29. pylxpweb/endpoints/devices.py +249 -16
  30. pylxpweb/endpoints/firmware.py +43 -10
  31. pylxpweb/endpoints/plants.py +15 -19
  32. pylxpweb/exceptions.py +4 -0
  33. pylxpweb/models.py +708 -41
  34. pylxpweb/transports/__init__.py +78 -0
  35. pylxpweb/transports/capabilities.py +101 -0
  36. pylxpweb/transports/data.py +501 -0
  37. pylxpweb/transports/exceptions.py +59 -0
  38. pylxpweb/transports/factory.py +119 -0
  39. pylxpweb/transports/http.py +329 -0
  40. pylxpweb/transports/modbus.py +617 -0
  41. pylxpweb/transports/protocol.py +217 -0
  42. {pylxpweb-0.1.0.dist-info → pylxpweb-0.5.2.dist-info}/METADATA +130 -85
  43. pylxpweb-0.5.2.dist-info/RECORD +52 -0
  44. {pylxpweb-0.1.0.dist-info → pylxpweb-0.5.2.dist-info}/WHEEL +1 -1
  45. pylxpweb-0.5.2.dist-info/entry_points.txt +3 -0
  46. pylxpweb-0.1.0.dist-info/RECORD +0 -19
@@ -0,0 +1,504 @@
1
+ """Firmware update detection mixin for devices.
2
+
3
+ This module provides the FirmwareUpdateMixin class that can be mixed into
4
+ any device class (BaseInverter, MIDDevice, etc.) to add firmware update
5
+ detection capabilities with caching and Home Assistant compatibility.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ from datetime import datetime, timedelta
12
+ from typing import TYPE_CHECKING
13
+
14
+ if TYPE_CHECKING:
15
+ from pylxpweb import LuxpowerClient
16
+ from pylxpweb.models import FirmwareUpdateInfo
17
+
18
+
19
+ class FirmwareUpdateMixin:
20
+ """Mixin class providing firmware update detection for devices.
21
+
22
+ This mixin adds:
23
+ - Firmware update checking with 24-hour caching
24
+ - Real-time progress tracking with adaptive caching
25
+ - Synchronous property access to cached update status
26
+ - Methods to start updates and check eligibility
27
+ - Full Home Assistant Update entity compatibility
28
+
29
+ Available properties (synchronous, cached):
30
+ - firmware_update_available: bool | None - Update availability
31
+ - firmware_update_in_progress: bool - Update currently in progress
32
+ - firmware_update_percentage: int | None - Progress percentage (0-100)
33
+ - latest_firmware_version: str | None - Latest version available
34
+ - firmware_update_title: str | None - Update title
35
+ - firmware_update_summary: str | None - Release summary
36
+ - firmware_update_url: str | None - Release notes URL
37
+
38
+ The mixin expects the following attributes on the implementing class:
39
+ - _client: LuxpowerClient instance
40
+ - serial_number: Device serial number (str)
41
+ - model: Device model name (str)
42
+
43
+ Example:
44
+ ```python
45
+ class MyDevice(FirmwareUpdateMixin, BaseDevice):
46
+ def __init__(self, client, serial_number, model):
47
+ super().__init__(client, serial_number, model)
48
+ self._init_firmware_update_cache()
49
+
50
+ # ... rest of device implementation
51
+ ```
52
+ """
53
+
54
+ def _init_firmware_update_cache(self) -> None:
55
+ """Initialize firmware update cache attributes.
56
+
57
+ This method must be called in the device's __init__ after super().__init__().
58
+ It initializes the cache attributes needed for firmware update detection.
59
+ """
60
+ self._firmware_update_info: FirmwareUpdateInfo | None = None
61
+ self._firmware_update_cache_time: datetime | None = None
62
+ self._firmware_update_cache_ttl = timedelta(hours=24) # 24-hour TTL
63
+ self._firmware_update_cache_lock = asyncio.Lock()
64
+
65
+ @property
66
+ def firmware_update_available(self) -> bool | None:
67
+ """Check if firmware update is available (from cache).
68
+
69
+ This property provides synchronous access to cached firmware update status.
70
+ Returns None if firmware check has never been performed.
71
+
72
+ To check for updates, call `check_firmware_updates()` first.
73
+
74
+ Returns:
75
+ True if update available, False if up to date, None if not checked yet.
76
+
77
+ Example:
78
+ >>> # First check for updates
79
+ >>> update_info = await device.check_firmware_updates()
80
+ >>> # Then access cached status
81
+ >>> if device.firmware_update_available:
82
+ ... print(f"Update available: {update_info.release_summary}")
83
+ """
84
+ if self._firmware_update_info is None:
85
+ return None
86
+ return self._firmware_update_info.update_available
87
+
88
+ @property
89
+ def latest_firmware_version(self) -> str | None:
90
+ """Get latest firmware version from cache.
91
+
92
+ Returns:
93
+ Latest firmware version string, or None if not checked yet.
94
+
95
+ Example:
96
+ >>> await device.check_firmware_updates()
97
+ >>> print(f"Latest version: {device.latest_firmware_version}")
98
+ """
99
+ if self._firmware_update_info is None:
100
+ return None
101
+ return self._firmware_update_info.latest_version
102
+
103
+ @property
104
+ def firmware_update_title(self) -> str | None:
105
+ """Get firmware update title from cache.
106
+
107
+ Returns:
108
+ Firmware update title, or None if not checked yet.
109
+
110
+ Example:
111
+ >>> await device.check_firmware_updates()
112
+ >>> print(f"Title: {device.firmware_update_title}")
113
+ """
114
+ if self._firmware_update_info is None:
115
+ return None
116
+ return self._firmware_update_info.title
117
+
118
+ @property
119
+ def firmware_update_summary(self) -> str | None:
120
+ """Get firmware update summary from cache.
121
+
122
+ Returns:
123
+ Firmware update release summary, or None if not checked yet.
124
+
125
+ Example:
126
+ >>> await device.check_firmware_updates()
127
+ >>> if device.firmware_update_summary:
128
+ ... print(f"Summary: {device.firmware_update_summary}")
129
+ """
130
+ if self._firmware_update_info is None:
131
+ return None
132
+ return self._firmware_update_info.release_summary
133
+
134
+ @property
135
+ def firmware_update_url(self) -> str | None:
136
+ """Get firmware update URL from cache.
137
+
138
+ Returns:
139
+ Firmware update release URL, or None if not checked yet.
140
+
141
+ Example:
142
+ >>> await device.check_firmware_updates()
143
+ >>> if device.firmware_update_url:
144
+ ... print(f"Release notes: {device.firmware_update_url}")
145
+ """
146
+ if self._firmware_update_info is None:
147
+ return None
148
+ return self._firmware_update_info.release_url
149
+
150
+ @property
151
+ def firmware_update_in_progress(self) -> bool:
152
+ """Check if firmware update is currently in progress (from cache).
153
+
154
+ This property provides synchronous access to cached firmware update progress status.
155
+ Returns False if no progress data available or if no update is in progress.
156
+
157
+ To get real-time progress, call `get_firmware_update_progress()` first.
158
+
159
+ Returns:
160
+ True if update is in progress, False otherwise.
161
+
162
+ Example:
163
+ >>> # Check progress
164
+ >>> await device.get_firmware_update_progress()
165
+ >>> # Access cached status
166
+ >>> if device.firmware_update_in_progress:
167
+ ... print(f"Update at {device.firmware_update_percentage}%")
168
+ """
169
+ if self._firmware_update_info is None:
170
+ return False
171
+ return self._firmware_update_info.in_progress
172
+
173
+ @property
174
+ def firmware_update_percentage(self) -> int | None:
175
+ """Get firmware update progress percentage (from cache).
176
+
177
+ This property provides synchronous access to cached firmware update progress percentage.
178
+ Returns None if no progress data available.
179
+
180
+ To get real-time progress, call `get_firmware_update_progress()` first.
181
+
182
+ Returns:
183
+ Progress percentage (0-100), or None if not available.
184
+
185
+ Example:
186
+ >>> # Check progress
187
+ >>> await device.get_firmware_update_progress()
188
+ >>> # Access cached percentage
189
+ >>> if device.firmware_update_percentage is not None:
190
+ ... print(f"Progress: {device.firmware_update_percentage}%")
191
+ """
192
+ if self._firmware_update_info is None:
193
+ return None
194
+ return self._firmware_update_info.update_percentage
195
+
196
+ async def check_firmware_updates(self, force: bool = False) -> FirmwareUpdateInfo:
197
+ """Check for available firmware updates (cached with 24-hour TTL).
198
+
199
+ This method checks the API for firmware updates and caches the result
200
+ for 24 hours. Subsequent calls within the cache period will return
201
+ cached data unless force=True.
202
+
203
+ The returned FirmwareUpdateInfo contains all fields needed for Home
204
+ Assistant Update entities, including installed_version, latest_version,
205
+ release_summary, release_url, and supported_features.
206
+
207
+ Args:
208
+ force: If True, bypass cache and force fresh check from API
209
+
210
+ Returns:
211
+ FirmwareUpdateInfo instance with HA-compatible update information.
212
+
213
+ Raises:
214
+ LuxpowerAPIError: If API check fails
215
+ LuxpowerConnectionError: If network connection fails
216
+
217
+ Example:
218
+ >>> # Check for updates (cached for 24 hours)
219
+ >>> update_info = await device.check_firmware_updates()
220
+ >>> if update_info.update_available:
221
+ ... print(f"New version: {update_info.latest_version}")
222
+ ... print(f"Summary: {update_info.release_summary}")
223
+ ... print(f"Release notes: {update_info.release_url}")
224
+ ...
225
+ >>> # Access cached status synchronously
226
+ >>> if device.firmware_update_available:
227
+ ... print("Update available!")
228
+ """
229
+ # Import here to avoid circular imports
230
+ from pylxpweb.models import FirmwareUpdateInfo
231
+
232
+ # Check cache
233
+ if not force:
234
+ async with self._firmware_update_cache_lock:
235
+ if (
236
+ self._firmware_update_cache_time is not None
237
+ and (datetime.now() - self._firmware_update_cache_time)
238
+ < self._firmware_update_cache_ttl
239
+ ):
240
+ assert self._firmware_update_info is not None
241
+ return self._firmware_update_info
242
+
243
+ # Fetch from API
244
+ client: LuxpowerClient = self._client # type: ignore[attr-defined]
245
+ serial: str = self.serial_number # type: ignore[attr-defined]
246
+ model: str = self.model # type: ignore[attr-defined]
247
+
248
+ check = await client.api.firmware.check_firmware_updates(serial)
249
+
250
+ # Create HA-friendly update info
251
+ title = f"{model} Firmware"
252
+ update_info = FirmwareUpdateInfo.from_api_response(check, title=title)
253
+
254
+ # Update cache
255
+ async with self._firmware_update_cache_lock:
256
+ self._firmware_update_info = update_info
257
+ self._firmware_update_cache_time = datetime.now()
258
+
259
+ return update_info
260
+
261
+ async def get_firmware_update_progress(self, force: bool = False) -> FirmwareUpdateInfo:
262
+ """Get real-time firmware update progress for this device.
263
+
264
+ This method queries the API for current firmware update status and returns
265
+ updated FirmwareUpdateInfo with real-time progress data.
266
+
267
+ Caching behavior (adaptive based on update status):
268
+ - During active updates (in_progress=True): 10-second cache for near real-time progress
269
+ - No active update (in_progress=False): 5-minute cache to reduce API load
270
+ - force=True: Always bypasses cache regardless of status
271
+
272
+ The short 10-second cache during updates provides fresh progress data while
273
+ preventing excessive API calls if multiple components poll simultaneously.
274
+
275
+ Use this method when:
276
+ - Monitoring active firmware update progress
277
+ - Checking if update is in progress
278
+ - Getting current update percentage during installation
279
+
280
+ The returned FirmwareUpdateInfo will have:
281
+ - in_progress: True if update is currently active (UPLOADING/READY)
282
+ - update_percentage: Current progress (0-100) parsed from API
283
+ - All other fields from cached firmware check
284
+
285
+ Args:
286
+ force: If True, bypass cache and force fresh check from API
287
+
288
+ Returns:
289
+ FirmwareUpdateInfo with real-time progress data
290
+
291
+ Raises:
292
+ LuxpowerAPIError: If API check fails
293
+ LuxpowerConnectionError: If network connection fails
294
+
295
+ Example:
296
+ >>> # Start monitoring after initiating update
297
+ >>> await device.start_firmware_update()
298
+ >>>
299
+ >>> # Poll for progress
300
+ >>> while True:
301
+ ... progress = await device.get_firmware_update_progress()
302
+ ... if not progress.in_progress:
303
+ ... break
304
+ ... print(f"Progress: {progress.update_percentage}%")
305
+ ... await asyncio.sleep(30) # Poll every 30 seconds
306
+ """
307
+ # Import here to avoid circular imports
308
+ import re
309
+
310
+ from pylxpweb.models import FirmwareUpdateInfo
311
+
312
+ client: LuxpowerClient = self._client # type: ignore[attr-defined]
313
+ serial: str = self.serial_number # type: ignore[attr-defined]
314
+
315
+ # Check cache (only if not forced)
316
+ # Note: We check cache age first, but if there's an active update,
317
+ # we need fresh data regardless of cache age. However, we can only
318
+ # know if there's an active update by checking the API, so we use
319
+ # a shorter TTL (30 seconds) to ensure we detect updates quickly
320
+ # while still reducing API load during normal operation.
321
+ if not force:
322
+ async with self._firmware_update_cache_lock:
323
+ if (
324
+ self._firmware_update_info is not None
325
+ and self._firmware_update_cache_time is not None
326
+ ):
327
+ cache_age = datetime.now() - self._firmware_update_cache_time
328
+
329
+ # Use different cache TTLs based on update status
330
+ if self._firmware_update_info.in_progress:
331
+ # During active update: use very short cache (10 seconds)
332
+ # to get near real-time progress
333
+ cache_ttl = timedelta(seconds=10)
334
+ else:
335
+ # No active update: use longer cache (5 minutes)
336
+ # to reduce API load
337
+ cache_ttl = timedelta(minutes=5)
338
+
339
+ if cache_age < cache_ttl:
340
+ return self._firmware_update_info
341
+
342
+ # Get current update status from API
343
+ status = await client.api.firmware.get_firmware_update_status()
344
+
345
+ # Find this device's progress info
346
+ device_info = next(
347
+ (info for info in status.deviceInfos if info.inverterSn == serial),
348
+ None,
349
+ )
350
+
351
+ # Determine progress state
352
+ in_progress = False
353
+ update_percentage: int | None = None
354
+
355
+ if device_info is not None:
356
+ # Check if update is in progress
357
+ in_progress = device_info.is_in_progress
358
+
359
+ # Parse percentage from updateRate string (e.g., "50% - 280 / 561")
360
+ if device_info.updateRate:
361
+ match = re.match(r"^(\d+)%", device_info.updateRate)
362
+ if match:
363
+ update_percentage = int(match.group(1))
364
+
365
+ # Get cached firmware check data (required for version info)
366
+ # If not cached, fetch it now
367
+ if self._firmware_update_info is None:
368
+ await self.check_firmware_updates()
369
+ assert self._firmware_update_info is not None
370
+
371
+ # Create updated FirmwareUpdateInfo with progress data
372
+ update_info = FirmwareUpdateInfo(
373
+ installed_version=self._firmware_update_info.installed_version,
374
+ latest_version=self._firmware_update_info.latest_version,
375
+ title=self._firmware_update_info.title,
376
+ release_summary=self._firmware_update_info.release_summary,
377
+ release_url=self._firmware_update_info.release_url,
378
+ in_progress=in_progress,
379
+ update_percentage=update_percentage,
380
+ device_class=self._firmware_update_info.device_class,
381
+ supported_features=self._firmware_update_info.supported_features,
382
+ app_version_current=self._firmware_update_info.app_version_current,
383
+ app_version_latest=self._firmware_update_info.app_version_latest,
384
+ param_version_current=self._firmware_update_info.param_version_current,
385
+ param_version_latest=self._firmware_update_info.param_version_latest,
386
+ )
387
+
388
+ # Update cache with progress data
389
+ async with self._firmware_update_cache_lock:
390
+ self._firmware_update_info = update_info
391
+ # Update timestamp: allows caching when no active update
392
+ self._firmware_update_cache_time = datetime.now()
393
+
394
+ return update_info
395
+
396
+ async def start_firmware_update(self, try_fast_mode: bool = False) -> bool:
397
+ """Start firmware update for this device.
398
+
399
+ ⚠️ CRITICAL WARNING - WRITE OPERATION
400
+ This initiates an actual firmware update that:
401
+ - Takes 20-40 minutes to complete
402
+ - Makes device unavailable during update
403
+ - Requires uninterrupted power and network
404
+ - May brick device if interrupted
405
+
406
+ Recommended workflow:
407
+ 1. Call check_firmware_updates() to verify update is available
408
+ 2. Call check_update_eligibility() to verify device is ready
409
+ 3. Get explicit user confirmation
410
+ 4. Call this method to start update
411
+ 5. Monitor progress with get_firmware_update_status()
412
+
413
+ Args:
414
+ try_fast_mode: Attempt fast update mode (may reduce time by 20-30%)
415
+
416
+ Returns:
417
+ Boolean indicating if update was initiated successfully
418
+
419
+ Raises:
420
+ LuxpowerAuthError: If authentication fails
421
+ LuxpowerAPIError: If update cannot be started (already updating,
422
+ no update available, parallel group updating)
423
+ LuxpowerConnectionError: If connection fails
424
+
425
+ Example:
426
+ >>> # Check for updates first
427
+ >>> update_info = await device.check_firmware_updates()
428
+ >>> if not update_info.update_available:
429
+ ... print("No update available")
430
+ ... return
431
+ ...
432
+ >>> # Check eligibility
433
+ >>> eligible = await device.check_update_eligibility()
434
+ >>> if not eligible:
435
+ ... print("Device not eligible for update")
436
+ ... return
437
+ ...
438
+ >>> # Get user confirmation
439
+ >>> if confirm_with_user():
440
+ ... success = await device.start_firmware_update()
441
+ ... if success:
442
+ ... print("Update started successfully")
443
+ """
444
+ # Import here to avoid circular imports
445
+ from pylxpweb.models import FirmwareUpdateInfo
446
+
447
+ client: LuxpowerClient = self._client # type: ignore[attr-defined]
448
+ serial: str = self.serial_number # type: ignore[attr-defined]
449
+
450
+ # Start the firmware update
451
+ success = await client.api.firmware.start_firmware_update(
452
+ serial, try_fast_mode=try_fast_mode
453
+ )
454
+
455
+ # Optimistic update: If successful, immediately set in_progress=True
456
+ # This ensures cache bypass logic activates right away for progress tracking
457
+ if success and self._firmware_update_info is not None:
458
+ async with self._firmware_update_cache_lock:
459
+ # Create updated info with in_progress=True and initial 0% progress
460
+ self._firmware_update_info = FirmwareUpdateInfo(
461
+ installed_version=self._firmware_update_info.installed_version,
462
+ latest_version=self._firmware_update_info.latest_version,
463
+ title=self._firmware_update_info.title,
464
+ release_summary=self._firmware_update_info.release_summary,
465
+ release_url=self._firmware_update_info.release_url,
466
+ in_progress=True, # Optimistically set to True
467
+ update_percentage=0, # Start at 0%
468
+ device_class=self._firmware_update_info.device_class,
469
+ supported_features=self._firmware_update_info.supported_features,
470
+ app_version_current=self._firmware_update_info.app_version_current,
471
+ app_version_latest=self._firmware_update_info.app_version_latest,
472
+ param_version_current=self._firmware_update_info.param_version_current,
473
+ param_version_latest=self._firmware_update_info.param_version_latest,
474
+ )
475
+ # Update timestamp so next progress call uses 10-second cache
476
+ self._firmware_update_cache_time = datetime.now()
477
+
478
+ return success
479
+
480
+ async def check_update_eligibility(self) -> bool:
481
+ """Check if this device is eligible for firmware update.
482
+
483
+ This is a READ-ONLY operation that verifies if the device can be updated.
484
+
485
+ Returns:
486
+ True if device is eligible for update, False otherwise
487
+
488
+ Raises:
489
+ LuxpowerAuthError: If authentication fails
490
+ LuxpowerAPIError: If API check fails
491
+ LuxpowerConnectionError: If connection fails
492
+
493
+ Example:
494
+ >>> eligible = await device.check_update_eligibility()
495
+ >>> if eligible:
496
+ ... await device.start_firmware_update()
497
+ >>> else:
498
+ ... print("Device is not eligible for update (may be updating already)")
499
+ """
500
+ client: LuxpowerClient = self._client # type: ignore[attr-defined]
501
+ serial: str = self.serial_number # type: ignore[attr-defined]
502
+
503
+ eligibility = await client.api.firmware.check_update_eligibility(serial)
504
+ return eligibility.is_allowed