pylxpweb 0.1.0__py3-none-any.whl → 0.5.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 +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 +545 -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 +351 -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 +629 -40
- pylxpweb/transports/__init__.py +78 -0
- pylxpweb/transports/capabilities.py +101 -0
- pylxpweb/transports/data.py +495 -0
- pylxpweb/transports/exceptions.py +59 -0
- pylxpweb/transports/factory.py +119 -0
- pylxpweb/transports/http.py +329 -0
- pylxpweb/transports/modbus.py +557 -0
- pylxpweb/transports/protocol.py +217 -0
- {pylxpweb-0.1.0.dist-info → pylxpweb-0.5.0.dist-info}/METADATA +130 -85
- pylxpweb-0.5.0.dist-info/RECORD +52 -0
- {pylxpweb-0.1.0.dist-info → pylxpweb-0.5.0.dist-info}/WHEEL +1 -1
- pylxpweb-0.5.0.dist-info/entry_points.txt +3 -0
- pylxpweb-0.1.0.dist-info/RECORD +0 -19
pylxpweb/endpoints/devices.py
CHANGED
|
@@ -15,8 +15,11 @@ from typing import TYPE_CHECKING
|
|
|
15
15
|
from pylxpweb.endpoints.base import BaseEndpoint
|
|
16
16
|
from pylxpweb.models import (
|
|
17
17
|
BatteryInfo,
|
|
18
|
+
BatteryListResponse,
|
|
19
|
+
DongleStatus,
|
|
18
20
|
EnergyInfo,
|
|
19
|
-
|
|
21
|
+
InverterInfo,
|
|
22
|
+
InverterOverviewResponse,
|
|
20
23
|
InverterRuntime,
|
|
21
24
|
MidboxRuntime,
|
|
22
25
|
ParallelGroupDetailsResponse,
|
|
@@ -37,26 +40,29 @@ class DeviceEndpoints(BaseEndpoint):
|
|
|
37
40
|
"""
|
|
38
41
|
super().__init__(client)
|
|
39
42
|
|
|
40
|
-
async def get_parallel_group_details(self,
|
|
41
|
-
"""Get parallel group device hierarchy for a
|
|
43
|
+
async def get_parallel_group_details(self, serial_num: str) -> ParallelGroupDetailsResponse:
|
|
44
|
+
"""Get parallel group device hierarchy for a specific device.
|
|
45
|
+
|
|
46
|
+
Note: This endpoint requires a device serial number, not a plant ID.
|
|
47
|
+
Use the GridBOSS/MID device serial number for parallel group details.
|
|
42
48
|
|
|
43
49
|
Args:
|
|
44
|
-
|
|
50
|
+
serial_num: Serial number of GridBOSS or any device in the parallel group
|
|
45
51
|
|
|
46
52
|
Returns:
|
|
47
53
|
ParallelGroupDetailsResponse: Parallel group structure
|
|
48
54
|
|
|
49
55
|
Example:
|
|
50
|
-
groups = await client.devices.get_parallel_group_details(
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
print(f"
|
|
56
|
+
groups = await client.devices.get_parallel_group_details("4524850115")
|
|
57
|
+
print(f"Total devices: {groups.total}")
|
|
58
|
+
for device in groups.devices:
|
|
59
|
+
print(f" {device.serialNum}: {device.roleText}")
|
|
54
60
|
"""
|
|
55
61
|
await self.client._ensure_authenticated()
|
|
56
62
|
|
|
57
|
-
data = {"
|
|
63
|
+
data = {"serialNum": serial_num}
|
|
58
64
|
|
|
59
|
-
cache_key = self._get_cache_key("parallel_groups",
|
|
65
|
+
cache_key = self._get_cache_key("parallel_groups", serialNum=serial_num)
|
|
60
66
|
response = await self.client._request(
|
|
61
67
|
"POST",
|
|
62
68
|
"/WManage/api/inverterOverview/getParallelGroupDetails",
|
|
@@ -66,23 +72,71 @@ class DeviceEndpoints(BaseEndpoint):
|
|
|
66
72
|
)
|
|
67
73
|
return ParallelGroupDetailsResponse.model_validate(response)
|
|
68
74
|
|
|
69
|
-
async def
|
|
70
|
-
"""
|
|
75
|
+
async def sync_parallel_groups(self, plant_id: int) -> bool:
|
|
76
|
+
"""Trigger automatic parallel group detection and synchronization.
|
|
77
|
+
|
|
78
|
+
This endpoint initializes/syncs parallel group data for all inverters
|
|
79
|
+
in a plant. Required when parallel group data is not available, such as:
|
|
80
|
+
- After firmware updates that reset parallel group settings
|
|
81
|
+
- When `get_parallel_group_details` returns empty/no data
|
|
82
|
+
- Initial setup of parallel inverter configurations with GridBOSS
|
|
83
|
+
|
|
84
|
+
Note:
|
|
85
|
+
This is a write operation that modifies parallel group configuration.
|
|
86
|
+
May take several seconds to complete as it communicates with all inverters.
|
|
87
|
+
Should be called once per plant, not per inverter.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
plant_id: Plant/station ID to sync parallel data for
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
bool: True if sync was successful, False otherwise
|
|
94
|
+
|
|
95
|
+
Example:
|
|
96
|
+
# If GridBOSS detected but no parallel groups
|
|
97
|
+
success = await client.api.devices.sync_parallel_groups(12345)
|
|
98
|
+
if success:
|
|
99
|
+
# Re-fetch parallel group details
|
|
100
|
+
groups = await client.api.devices.get_parallel_group_details(gridboss_serial)
|
|
101
|
+
"""
|
|
102
|
+
await self.client._ensure_authenticated()
|
|
103
|
+
|
|
104
|
+
data = {"plantId": plant_id}
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
response = await self.client._request(
|
|
108
|
+
"POST",
|
|
109
|
+
"/WManage/api/inverter/autoParallel",
|
|
110
|
+
data=data,
|
|
111
|
+
)
|
|
112
|
+
# Response should have success field
|
|
113
|
+
return bool(response.get("success", False))
|
|
114
|
+
except Exception:
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
async def get_devices(self, plant_id: int) -> InverterOverviewResponse:
|
|
118
|
+
"""Get overview/status of all devices in a plant.
|
|
71
119
|
|
|
72
120
|
Args:
|
|
73
121
|
plant_id: Plant/station ID
|
|
74
122
|
|
|
75
123
|
Returns:
|
|
76
|
-
|
|
124
|
+
InverterOverviewResponse: Overview data for inverters and devices
|
|
77
125
|
|
|
78
126
|
Example:
|
|
79
127
|
devices = await client.devices.get_devices(12345)
|
|
80
128
|
for device in devices.rows:
|
|
81
|
-
print(f"Device: {device.serialNum} - {device.
|
|
129
|
+
print(f"Device: {device.serialNum} - {device.statusText}")
|
|
82
130
|
"""
|
|
83
131
|
await self.client._ensure_authenticated()
|
|
84
132
|
|
|
85
|
-
data = {
|
|
133
|
+
data = {
|
|
134
|
+
"page": 1,
|
|
135
|
+
"rows": 30,
|
|
136
|
+
"plantId": plant_id,
|
|
137
|
+
"searchText": "",
|
|
138
|
+
"statusText": "all",
|
|
139
|
+
}
|
|
86
140
|
|
|
87
141
|
cache_key = self._get_cache_key("devices", plantId=plant_id)
|
|
88
142
|
response = await self.client._request(
|
|
@@ -92,7 +146,40 @@ class DeviceEndpoints(BaseEndpoint):
|
|
|
92
146
|
cache_key=cache_key,
|
|
93
147
|
cache_endpoint="device_discovery",
|
|
94
148
|
)
|
|
95
|
-
return
|
|
149
|
+
return InverterOverviewResponse.model_validate(response)
|
|
150
|
+
|
|
151
|
+
async def get_inverter_info(self, serial_num: str) -> InverterInfo:
|
|
152
|
+
"""Get detailed inverter configuration and device information.
|
|
153
|
+
|
|
154
|
+
This endpoint returns static device configuration, firmware versions,
|
|
155
|
+
and hardware details. For real-time operational data, use get_inverter_runtime().
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
serial_num: Inverter serial number
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
InverterInfo: Detailed device configuration
|
|
162
|
+
|
|
163
|
+
Example:
|
|
164
|
+
info = await client.devices.get_inverter_info("1234567890")
|
|
165
|
+
print(f"Device: {info.deviceTypeText}")
|
|
166
|
+
print(f"Firmware: {info.inverterDetail.fwCode}")
|
|
167
|
+
print(f"Power Rating: {info.powerRatingText}")
|
|
168
|
+
print(f"Battery Type: {info.batteryType}")
|
|
169
|
+
"""
|
|
170
|
+
await self.client._ensure_authenticated()
|
|
171
|
+
|
|
172
|
+
data = {"serialNum": serial_num}
|
|
173
|
+
|
|
174
|
+
cache_key = self._get_cache_key("inverter_info", serialNum=serial_num)
|
|
175
|
+
response = await self.client._request(
|
|
176
|
+
"POST",
|
|
177
|
+
"/WManage/api/inverter/getInverterInfo",
|
|
178
|
+
data=data,
|
|
179
|
+
cache_key=cache_key,
|
|
180
|
+
cache_endpoint="device_discovery", # Static data, cache like device discovery
|
|
181
|
+
)
|
|
182
|
+
return InverterInfo.model_validate(response)
|
|
96
183
|
|
|
97
184
|
async def get_inverter_runtime(self, serial_num: str) -> InverterRuntime:
|
|
98
185
|
"""Get real-time runtime data for an inverter.
|
|
@@ -218,6 +305,39 @@ class DeviceEndpoints(BaseEndpoint):
|
|
|
218
305
|
)
|
|
219
306
|
return BatteryInfo.model_validate(response)
|
|
220
307
|
|
|
308
|
+
async def get_battery_list(self, serial_num: str) -> BatteryListResponse:
|
|
309
|
+
"""Get simplified battery list for an inverter.
|
|
310
|
+
|
|
311
|
+
This endpoint returns only battery identification and status without detailed metrics.
|
|
312
|
+
Use get_battery_info() for full battery metrics including voltage, current, SOC, etc.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
serial_num: Inverter serial number
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
BatteryListResponse: Simplified battery list with keys and status
|
|
319
|
+
|
|
320
|
+
Example:
|
|
321
|
+
batteries = await client.devices.get_battery_list("1234567890")
|
|
322
|
+
print(f"Total batteries: {batteries.totalNumber}")
|
|
323
|
+
for battery in batteries.batteryArray:
|
|
324
|
+
status = "Online" if not battery.lost else "Offline"
|
|
325
|
+
print(f" Battery {battery.batIndex}: {battery.batterySn} ({status})")
|
|
326
|
+
"""
|
|
327
|
+
await self.client._ensure_authenticated()
|
|
328
|
+
|
|
329
|
+
data = {"serialNum": serial_num}
|
|
330
|
+
|
|
331
|
+
cache_key = self._get_cache_key("battery_list", serialNum=serial_num)
|
|
332
|
+
response = await self.client._request(
|
|
333
|
+
"POST",
|
|
334
|
+
"/WManage/api/battery/getBatteryInfoForSet",
|
|
335
|
+
data=data,
|
|
336
|
+
cache_key=cache_key,
|
|
337
|
+
cache_endpoint="battery_info",
|
|
338
|
+
)
|
|
339
|
+
return BatteryListResponse.model_validate(response)
|
|
340
|
+
|
|
221
341
|
async def get_midbox_runtime(self, serial_num: str) -> MidboxRuntime:
|
|
222
342
|
"""Get GridBOSS/MID device runtime data.
|
|
223
343
|
|
|
@@ -248,3 +368,116 @@ class DeviceEndpoints(BaseEndpoint):
|
|
|
248
368
|
cache_endpoint="midbox_runtime",
|
|
249
369
|
)
|
|
250
370
|
return MidboxRuntime.model_validate(response)
|
|
371
|
+
|
|
372
|
+
async def get_dongle_status(self, datalog_serial: str) -> DongleStatus:
|
|
373
|
+
"""Get dongle (datalog) connection status.
|
|
374
|
+
|
|
375
|
+
The dongle is the communication module that connects inverters to the
|
|
376
|
+
cloud monitoring service. This endpoint checks if the dongle is currently
|
|
377
|
+
online and communicating.
|
|
378
|
+
|
|
379
|
+
Use this to determine if device data is current or potentially stale.
|
|
380
|
+
When the dongle is offline, the inverter data shown in the API may be
|
|
381
|
+
outdated since no new data is being received from the device.
|
|
382
|
+
|
|
383
|
+
Note: The datalog serial number is different from the inverter serial number.
|
|
384
|
+
You can get the datalog serial from InverterInfo.datalogSn.
|
|
385
|
+
|
|
386
|
+
Args:
|
|
387
|
+
datalog_serial: Dongle/datalog serial number (e.g., "BC34000380")
|
|
388
|
+
|
|
389
|
+
Returns:
|
|
390
|
+
DongleStatus: Dongle connection status with is_online property
|
|
391
|
+
|
|
392
|
+
Example:
|
|
393
|
+
# Get dongle serial from inverter info
|
|
394
|
+
info = await client.devices.get_inverter_info("4512670118")
|
|
395
|
+
datalog_sn = info.datalogSn
|
|
396
|
+
|
|
397
|
+
# Check if dongle is online
|
|
398
|
+
status = await client.devices.get_dongle_status(datalog_sn)
|
|
399
|
+
if status.is_online:
|
|
400
|
+
print("Dongle is online - data is current")
|
|
401
|
+
else:
|
|
402
|
+
print("Dongle is offline - data may be stale")
|
|
403
|
+
"""
|
|
404
|
+
await self.client._ensure_authenticated()
|
|
405
|
+
|
|
406
|
+
data = {"serialNum": datalog_serial}
|
|
407
|
+
|
|
408
|
+
response = await self.client._request(
|
|
409
|
+
"POST",
|
|
410
|
+
"/WManage/api/system/cluster/search/findOnlineDatalog",
|
|
411
|
+
data=data,
|
|
412
|
+
)
|
|
413
|
+
return DongleStatus.model_validate(response)
|
|
414
|
+
|
|
415
|
+
# ============================================================================
|
|
416
|
+
# Convenience Methods
|
|
417
|
+
# ============================================================================
|
|
418
|
+
|
|
419
|
+
async def get_all_device_data(
|
|
420
|
+
self, plant_id: int
|
|
421
|
+
) -> dict[str, InverterOverviewResponse | dict[str, InverterRuntime] | dict[str, BatteryInfo]]:
|
|
422
|
+
"""Get all device discovery and runtime data in a single call.
|
|
423
|
+
|
|
424
|
+
This method combines multiple API calls into one convenient method:
|
|
425
|
+
1. Device discovery (get_devices)
|
|
426
|
+
2. Runtime data for all inverters (get_inverter_runtime)
|
|
427
|
+
3. Battery info for all inverters (get_battery_info)
|
|
428
|
+
|
|
429
|
+
All API calls are made concurrently for optimal performance.
|
|
430
|
+
|
|
431
|
+
Args:
|
|
432
|
+
plant_id: Station/plant ID
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
dict: Combined data with keys:
|
|
436
|
+
- "devices": InverterOverviewResponse (device hierarchy)
|
|
437
|
+
- "runtime": dict[serial_num, InverterRuntime] (runtime data)
|
|
438
|
+
- "batteries": dict[serial_num, BatteryInfo] (battery data)
|
|
439
|
+
|
|
440
|
+
Example:
|
|
441
|
+
>>> data = await client.devices.get_all_device_data(12345)
|
|
442
|
+
>>> devices = data["devices"]
|
|
443
|
+
>>> for inverter in devices.inverters:
|
|
444
|
+
>>> runtime = data["runtime"].get(inverter["serialNum"])
|
|
445
|
+
>>> if runtime:
|
|
446
|
+
>>> print(f"Inverter {inverter['serialNum']}: {runtime.pac}W")
|
|
447
|
+
"""
|
|
448
|
+
import asyncio
|
|
449
|
+
|
|
450
|
+
# Get device list first
|
|
451
|
+
devices = await self.get_devices(plant_id)
|
|
452
|
+
|
|
453
|
+
# Extract all inverter serial numbers (excluding MID devices)
|
|
454
|
+
inverter_serials: list[str] = []
|
|
455
|
+
for device in devices.rows:
|
|
456
|
+
# Filter for actual inverters (not GridBOSS/MID devices)
|
|
457
|
+
if "Grid Boss" not in device.deviceTypeText:
|
|
458
|
+
inverter_serials.append(device.serialNum)
|
|
459
|
+
|
|
460
|
+
# Fetch runtime and battery data concurrently for all inverters
|
|
461
|
+
runtime_tasks = [self.get_inverter_runtime(sn) for sn in inverter_serials]
|
|
462
|
+
battery_tasks = [self.get_battery_info(sn) for sn in inverter_serials]
|
|
463
|
+
|
|
464
|
+
runtime_results = await asyncio.gather(*runtime_tasks, return_exceptions=True)
|
|
465
|
+
battery_results = await asyncio.gather(*battery_tasks, return_exceptions=True)
|
|
466
|
+
|
|
467
|
+
# Build result dictionaries
|
|
468
|
+
runtime_data: dict[str, InverterRuntime] = {}
|
|
469
|
+
battery_data: dict[str, BatteryInfo] = {}
|
|
470
|
+
|
|
471
|
+
for sn, runtime in zip(inverter_serials, runtime_results, strict=True):
|
|
472
|
+
if not isinstance(runtime, BaseException):
|
|
473
|
+
runtime_data[sn] = runtime
|
|
474
|
+
|
|
475
|
+
for sn, battery in zip(inverter_serials, battery_results, strict=True):
|
|
476
|
+
if not isinstance(battery, BaseException):
|
|
477
|
+
battery_data[sn] = battery
|
|
478
|
+
|
|
479
|
+
return {
|
|
480
|
+
"devices": devices,
|
|
481
|
+
"runtime": runtime_data,
|
|
482
|
+
"batteries": battery_data,
|
|
483
|
+
}
|
pylxpweb/endpoints/firmware.py
CHANGED
|
@@ -9,9 +9,11 @@ This module provides firmware update functionality including:
|
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
|
+
import logging
|
|
12
13
|
from typing import TYPE_CHECKING
|
|
13
14
|
|
|
14
15
|
from pylxpweb.endpoints.base import BaseEndpoint
|
|
16
|
+
from pylxpweb.exceptions import LuxpowerAPIError
|
|
15
17
|
from pylxpweb.models import (
|
|
16
18
|
FirmwareUpdateCheck,
|
|
17
19
|
FirmwareUpdateStatus,
|
|
@@ -21,6 +23,15 @@ from pylxpweb.models import (
|
|
|
21
23
|
if TYPE_CHECKING:
|
|
22
24
|
from pylxpweb.client import LuxpowerClient
|
|
23
25
|
|
|
26
|
+
_LOGGER = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
# Messages that indicate firmware is already up to date (not an error)
|
|
29
|
+
FIRMWARE_UP_TO_DATE_MESSAGES = (
|
|
30
|
+
"already the latest version",
|
|
31
|
+
"firmware is already the latest",
|
|
32
|
+
"already up to date",
|
|
33
|
+
)
|
|
34
|
+
|
|
24
35
|
|
|
25
36
|
class FirmwareEndpoints(BaseEndpoint):
|
|
26
37
|
"""Firmware update endpoints for checking and managing device firmware."""
|
|
@@ -39,22 +50,27 @@ class FirmwareEndpoints(BaseEndpoint):
|
|
|
39
50
|
This is a READ-ONLY operation that checks if firmware updates are available
|
|
40
51
|
and returns information about the current and available firmware versions.
|
|
41
52
|
|
|
53
|
+
When firmware is already up to date, the API returns success=false with a
|
|
54
|
+
message like "The current machine firmware is already the latest version".
|
|
55
|
+
This method handles that case gracefully by returning a FirmwareUpdateCheck
|
|
56
|
+
with success=True and details indicating no update is available.
|
|
57
|
+
|
|
42
58
|
Args:
|
|
43
59
|
serial_num: Device serial number (10-digit string)
|
|
44
60
|
|
|
45
61
|
Returns:
|
|
46
62
|
FirmwareUpdateCheck object containing:
|
|
47
|
-
- success: Boolean indicating
|
|
63
|
+
- success: Boolean indicating the check completed successfully
|
|
48
64
|
- details: Detailed firmware information including:
|
|
49
65
|
- Current firmware versions (v1, v2, v3)
|
|
50
|
-
- Latest available versions (lastV1, lastV2)
|
|
66
|
+
- Latest available versions (lastV1, lastV2) - None if up to date
|
|
51
67
|
- Update compatibility flags
|
|
52
68
|
- Device type information
|
|
53
69
|
- infoForwardUrl: URL to firmware changelog/release notes (optional)
|
|
54
70
|
|
|
55
71
|
Raises:
|
|
56
72
|
LuxpowerAuthError: If authentication fails
|
|
57
|
-
LuxpowerAPIError: If API returns an error
|
|
73
|
+
LuxpowerAPIError: If API returns an actual error (not "up to date" message)
|
|
58
74
|
LuxpowerConnectionError: If connection fails
|
|
59
75
|
|
|
60
76
|
Example:
|
|
@@ -62,18 +78,35 @@ class FirmwareEndpoints(BaseEndpoint):
|
|
|
62
78
|
if update_info.details.has_update():
|
|
63
79
|
print(f"Update available: {update_info.details.lastV1FileName}")
|
|
64
80
|
print(f"Changelog: {update_info.infoForwardUrl}")
|
|
81
|
+
else:
|
|
82
|
+
print("Firmware is already up to date")
|
|
65
83
|
"""
|
|
66
84
|
await self.client._ensure_authenticated()
|
|
67
85
|
|
|
68
86
|
data = {"serialNum": serial_num}
|
|
69
87
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
88
|
+
try:
|
|
89
|
+
response = await self.client._request(
|
|
90
|
+
"POST",
|
|
91
|
+
"/WManage/web/maintain/standardUpdate/checkUpdates",
|
|
92
|
+
data=data,
|
|
93
|
+
)
|
|
94
|
+
return FirmwareUpdateCheck.model_validate(response)
|
|
95
|
+
|
|
96
|
+
except LuxpowerAPIError as err:
|
|
97
|
+
# Check if this is an "already up to date" message (not a real error)
|
|
98
|
+
error_msg = str(err).lower()
|
|
99
|
+
if any(msg in error_msg for msg in FIRMWARE_UP_TO_DATE_MESSAGES):
|
|
100
|
+
_LOGGER.debug(
|
|
101
|
+
"Firmware is already up to date for device %s",
|
|
102
|
+
serial_num,
|
|
103
|
+
)
|
|
104
|
+
# Return a FirmwareUpdateCheck indicating no update available
|
|
105
|
+
# We create minimal details since we don't have full version info
|
|
106
|
+
return FirmwareUpdateCheck.create_up_to_date(serial_num)
|
|
107
|
+
|
|
108
|
+
# Re-raise if it's a different error
|
|
109
|
+
raise
|
|
77
110
|
|
|
78
111
|
async def get_firmware_update_status(self) -> FirmwareUpdateStatus:
|
|
79
112
|
"""Get firmware update status for all devices in user's account.
|
pylxpweb/endpoints/plants.py
CHANGED
|
@@ -10,6 +10,7 @@ This module provides plant/station functionality including:
|
|
|
10
10
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
|
+
import logging
|
|
13
14
|
from typing import TYPE_CHECKING, Any
|
|
14
15
|
|
|
15
16
|
from pylxpweb.endpoints.base import BaseEndpoint
|
|
@@ -18,6 +19,8 @@ from pylxpweb.models import PlantListResponse
|
|
|
18
19
|
if TYPE_CHECKING:
|
|
19
20
|
from pylxpweb.client import LuxpowerClient
|
|
20
21
|
|
|
22
|
+
_LOGGER = logging.getLogger(__name__)
|
|
23
|
+
|
|
21
24
|
|
|
22
25
|
class PlantEndpoints(BaseEndpoint):
|
|
23
26
|
"""Plant/Station endpoints for discovery, configuration, and overview."""
|
|
@@ -83,15 +86,14 @@ class PlantEndpoints(BaseEndpoint):
|
|
|
83
86
|
- name: Station name
|
|
84
87
|
- nominalPower: Solar PV power rating (W)
|
|
85
88
|
- timezone: Timezone string (e.g., "GMT -8")
|
|
89
|
+
- currentTimezoneWithMinute: Timezone offset in minutes
|
|
86
90
|
- daylightSavingTime: DST enabled (boolean)
|
|
87
|
-
-
|
|
88
|
-
- region: Region enum value
|
|
89
|
-
- country: Country enum value
|
|
90
|
-
- longitude: Geographic coordinate
|
|
91
|
-
- latitude: Geographic coordinate
|
|
91
|
+
- country: Country name
|
|
92
92
|
- createDate: Plant creation date
|
|
93
93
|
- address: Physical address
|
|
94
94
|
|
|
95
|
+
Note: Latitude and longitude coordinates are NOT included in the API response.
|
|
96
|
+
|
|
95
97
|
Raises:
|
|
96
98
|
LuxpowerAPIError: If plant not found or API error occurs
|
|
97
99
|
|
|
@@ -147,10 +149,8 @@ class PlantEndpoints(BaseEndpoint):
|
|
|
147
149
|
LuxpowerAPIError: If country cannot be found in locale API
|
|
148
150
|
"""
|
|
149
151
|
import json
|
|
150
|
-
from logging import getLogger
|
|
151
152
|
|
|
152
|
-
_LOGGER
|
|
153
|
-
_LOGGER.info(
|
|
153
|
+
_LOGGER.debug(
|
|
154
154
|
"Country '%s' not in static mapping, fetching from locale API",
|
|
155
155
|
country_human,
|
|
156
156
|
)
|
|
@@ -190,7 +190,7 @@ class PlantEndpoints(BaseEndpoint):
|
|
|
190
190
|
# Check if our country is in this region
|
|
191
191
|
for country in countries:
|
|
192
192
|
if country["text"] == country_human:
|
|
193
|
-
_LOGGER.
|
|
193
|
+
_LOGGER.debug(
|
|
194
194
|
"Found country '%s' in locale API: continent=%s, region=%s",
|
|
195
195
|
country_human,
|
|
196
196
|
continent_enum,
|
|
@@ -226,16 +226,12 @@ class PlantEndpoints(BaseEndpoint):
|
|
|
226
226
|
ValueError: If unable to map required fields
|
|
227
227
|
LuxpowerAPIError: If dynamic fetch fails
|
|
228
228
|
"""
|
|
229
|
-
from logging import getLogger
|
|
230
|
-
|
|
231
229
|
from pylxpweb.constants import (
|
|
232
230
|
COUNTRY_MAP,
|
|
233
231
|
TIMEZONE_MAP,
|
|
234
232
|
get_continent_region_from_country,
|
|
235
233
|
)
|
|
236
234
|
|
|
237
|
-
_LOGGER = getLogger(__name__)
|
|
238
|
-
|
|
239
235
|
# Required fields for POST
|
|
240
236
|
data: dict[str, Any] = {
|
|
241
237
|
"plantId": str(plant_details["plantId"]),
|
|
@@ -268,7 +264,7 @@ class PlantEndpoints(BaseEndpoint):
|
|
|
268
264
|
)
|
|
269
265
|
except ValueError:
|
|
270
266
|
# Slow path: dynamic fetch from locale API
|
|
271
|
-
_LOGGER.
|
|
267
|
+
_LOGGER.debug(
|
|
272
268
|
"Country '%s' not in static mapping, fetching from locale API",
|
|
273
269
|
country_human,
|
|
274
270
|
)
|
|
@@ -284,7 +280,7 @@ class PlantEndpoints(BaseEndpoint):
|
|
|
284
280
|
# Apply any overrides
|
|
285
281
|
data.update(overrides)
|
|
286
282
|
|
|
287
|
-
_LOGGER.
|
|
283
|
+
_LOGGER.debug(
|
|
288
284
|
"Prepared plant update data for plant %s: timezone=%s, country=%s, "
|
|
289
285
|
"continent=%s, region=%s, dst=%s",
|
|
290
286
|
plant_details["plantId"],
|
|
@@ -336,13 +332,13 @@ class PlantEndpoints(BaseEndpoint):
|
|
|
336
332
|
await self.client._ensure_authenticated()
|
|
337
333
|
|
|
338
334
|
# Get current configuration from API (human-readable values)
|
|
339
|
-
_LOGGER.
|
|
335
|
+
_LOGGER.debug("Fetching plant details for plant %s", plant_id)
|
|
340
336
|
plant_details = await self.get_plant_details(plant_id)
|
|
341
337
|
|
|
342
338
|
# Prepare POST data using hybrid approach (static + dynamic mapping)
|
|
343
339
|
data = await self._prepare_plant_update_data(plant_details, **kwargs)
|
|
344
340
|
|
|
345
|
-
_LOGGER.
|
|
341
|
+
_LOGGER.debug(
|
|
346
342
|
"Updating plant %s configuration: %s",
|
|
347
343
|
plant_id,
|
|
348
344
|
dict(kwargs),
|
|
@@ -350,7 +346,7 @@ class PlantEndpoints(BaseEndpoint):
|
|
|
350
346
|
|
|
351
347
|
response = await self.client._request("POST", "/WManage/web/config/plant/edit", data=data)
|
|
352
348
|
|
|
353
|
-
_LOGGER.
|
|
349
|
+
_LOGGER.debug("Plant %s configuration updated successfully", plant_id)
|
|
354
350
|
return response
|
|
355
351
|
|
|
356
352
|
async def set_daylight_saving_time(self, plant_id: int | str, enabled: bool) -> dict[str, Any]:
|
|
@@ -378,7 +374,7 @@ class PlantEndpoints(BaseEndpoint):
|
|
|
378
374
|
from logging import getLogger
|
|
379
375
|
|
|
380
376
|
_LOGGER = getLogger(__name__)
|
|
381
|
-
_LOGGER.
|
|
377
|
+
_LOGGER.debug(
|
|
382
378
|
"Setting Daylight Saving Time to %s for plant %s",
|
|
383
379
|
"enabled" if enabled else "disabled",
|
|
384
380
|
plant_id,
|
pylxpweb/exceptions.py
CHANGED
|
@@ -21,3 +21,7 @@ class LuxpowerAPIError(LuxpowerError):
|
|
|
21
21
|
|
|
22
22
|
class LuxpowerDeviceError(LuxpowerError):
|
|
23
23
|
"""Raised when there's an issue with device operations."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class LuxpowerDeviceOfflineError(LuxpowerDeviceError):
|
|
27
|
+
"""Raised when a device is offline or unreachable."""
|