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.
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 +545 -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 +351 -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 +629 -40
  34. pylxpweb/transports/__init__.py +78 -0
  35. pylxpweb/transports/capabilities.py +101 -0
  36. pylxpweb/transports/data.py +495 -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 +557 -0
  41. pylxpweb/transports/protocol.py +217 -0
  42. {pylxpweb-0.1.0.dist-info → pylxpweb-0.5.0.dist-info}/METADATA +130 -85
  43. pylxpweb-0.5.0.dist-info/RECORD +52 -0
  44. {pylxpweb-0.1.0.dist-info → pylxpweb-0.5.0.dist-info}/WHEEL +1 -1
  45. pylxpweb-0.5.0.dist-info/entry_points.txt +3 -0
  46. pylxpweb-0.1.0.dist-info/RECORD +0 -19
@@ -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
- InverterListResponse,
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, plant_id: int) -> ParallelGroupDetailsResponse:
41
- """Get parallel group device hierarchy for a plant.
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
- plant_id: Plant/station ID
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(12345)
51
- for group in groups.rows:
52
- print(f"Group ID: {group.groupId}")
53
- print(f"Inverters: {len(group.inverters)}")
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 = {"plantId": plant_id}
63
+ data = {"serialNum": serial_num}
58
64
 
59
- cache_key = self._get_cache_key("parallel_groups", plantId=plant_id)
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 get_devices(self, plant_id: int) -> InverterListResponse:
70
- """Get list of all devices in a plant.
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
- InverterListResponse: List of inverters and devices
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.alias}")
129
+ print(f"Device: {device.serialNum} - {device.statusText}")
82
130
  """
83
131
  await self.client._ensure_authenticated()
84
132
 
85
- data = {"plantId": plant_id}
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 InverterListResponse.model_validate(response)
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
+ }
@@ -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 success
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
- response = await self.client._request(
71
- "POST",
72
- "/WManage/web/maintain/standardUpdate/checkUpdates",
73
- data=data,
74
- )
75
-
76
- return FirmwareUpdateCheck.model_validate(response)
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.
@@ -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
- - continent: Continent enum value
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 = getLogger(__name__)
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.info(
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.info(
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.info(
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.info("Fetching plant details for plant %s", plant_id)
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.info(
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.info("Plant %s configuration updated successfully", plant_id)
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.info(
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."""