pylxpweb 0.1.0__py3-none-any.whl → 0.5.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pylxpweb/__init__.py +47 -2
- pylxpweb/api_namespace.py +241 -0
- pylxpweb/cli/__init__.py +3 -0
- pylxpweb/cli/collect_device_data.py +874 -0
- pylxpweb/client.py +387 -26
- pylxpweb/constants/__init__.py +481 -0
- pylxpweb/constants/api.py +48 -0
- pylxpweb/constants/devices.py +98 -0
- pylxpweb/constants/locations.py +227 -0
- pylxpweb/{constants.py → constants/registers.py} +72 -238
- pylxpweb/constants/scaling.py +479 -0
- pylxpweb/devices/__init__.py +32 -0
- pylxpweb/devices/_firmware_update_mixin.py +504 -0
- pylxpweb/devices/_mid_runtime_properties.py +1427 -0
- pylxpweb/devices/base.py +122 -0
- pylxpweb/devices/battery.py +589 -0
- pylxpweb/devices/battery_bank.py +331 -0
- pylxpweb/devices/inverters/__init__.py +32 -0
- pylxpweb/devices/inverters/_features.py +378 -0
- pylxpweb/devices/inverters/_runtime_properties.py +596 -0
- pylxpweb/devices/inverters/base.py +2124 -0
- pylxpweb/devices/inverters/generic.py +192 -0
- pylxpweb/devices/inverters/hybrid.py +274 -0
- pylxpweb/devices/mid_device.py +183 -0
- pylxpweb/devices/models.py +126 -0
- pylxpweb/devices/parallel_group.py +364 -0
- pylxpweb/devices/station.py +908 -0
- pylxpweb/endpoints/control.py +980 -2
- pylxpweb/endpoints/devices.py +249 -16
- pylxpweb/endpoints/firmware.py +43 -10
- pylxpweb/endpoints/plants.py +15 -19
- pylxpweb/exceptions.py +4 -0
- pylxpweb/models.py +708 -41
- pylxpweb/transports/__init__.py +78 -0
- pylxpweb/transports/capabilities.py +101 -0
- pylxpweb/transports/data.py +501 -0
- pylxpweb/transports/exceptions.py +59 -0
- pylxpweb/transports/factory.py +119 -0
- pylxpweb/transports/http.py +329 -0
- pylxpweb/transports/modbus.py +617 -0
- pylxpweb/transports/protocol.py +217 -0
- {pylxpweb-0.1.0.dist-info → pylxpweb-0.5.2.dist-info}/METADATA +130 -85
- pylxpweb-0.5.2.dist-info/RECORD +52 -0
- {pylxpweb-0.1.0.dist-info → pylxpweb-0.5.2.dist-info}/WHEEL +1 -1
- pylxpweb-0.5.2.dist-info/entry_points.txt +3 -0
- pylxpweb-0.1.0.dist-info/RECORD +0 -19
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Transport-specific exceptions.
|
|
2
|
+
|
|
3
|
+
This module provides exception classes for transport operations,
|
|
4
|
+
allowing clients to handle errors appropriately.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TransportError(Exception):
|
|
11
|
+
"""Base exception for all transport errors."""
|
|
12
|
+
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TransportConnectionError(TransportError):
|
|
17
|
+
"""Failed to connect to the device."""
|
|
18
|
+
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TransportTimeoutError(TransportError):
|
|
23
|
+
"""Operation timed out."""
|
|
24
|
+
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TransportReadError(TransportError):
|
|
29
|
+
"""Failed to read data from device."""
|
|
30
|
+
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TransportWriteError(TransportError):
|
|
35
|
+
"""Failed to write data to device."""
|
|
36
|
+
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class UnsupportedOperationError(TransportError):
|
|
41
|
+
"""Operation not supported by this transport.
|
|
42
|
+
|
|
43
|
+
Raised when attempting an operation that the transport
|
|
44
|
+
doesn't support (e.g., reading history via Modbus).
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, operation: str, transport_type: str) -> None:
|
|
48
|
+
"""Initialize with operation and transport details.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
operation: The operation that was attempted
|
|
52
|
+
transport_type: The type of transport that doesn't support it
|
|
53
|
+
"""
|
|
54
|
+
self.operation = operation
|
|
55
|
+
self.transport_type = transport_type
|
|
56
|
+
super().__init__(
|
|
57
|
+
f"Operation '{operation}' is not supported by {transport_type} transport. "
|
|
58
|
+
"Use HTTP transport for this feature."
|
|
59
|
+
)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Factory functions for creating transport instances.
|
|
2
|
+
|
|
3
|
+
This module provides convenience functions to create transport instances
|
|
4
|
+
for communicating with Luxpower/EG4 inverters via different protocols.
|
|
5
|
+
|
|
6
|
+
Example:
|
|
7
|
+
# HTTP Transport (cloud API)
|
|
8
|
+
async with LuxpowerClient(username, password) as client:
|
|
9
|
+
transport = create_http_transport(client, serial="CE12345678")
|
|
10
|
+
await transport.connect()
|
|
11
|
+
runtime = await transport.read_runtime()
|
|
12
|
+
|
|
13
|
+
# Modbus Transport (local network)
|
|
14
|
+
transport = create_modbus_transport(
|
|
15
|
+
host="192.168.1.100",
|
|
16
|
+
serial="CE12345678",
|
|
17
|
+
)
|
|
18
|
+
async with transport:
|
|
19
|
+
runtime = await transport.read_runtime()
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import TYPE_CHECKING
|
|
25
|
+
|
|
26
|
+
from .http import HTTPTransport
|
|
27
|
+
from .modbus import ModbusTransport
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from pylxpweb import LuxpowerClient
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def create_http_transport(
|
|
34
|
+
client: LuxpowerClient,
|
|
35
|
+
serial: str,
|
|
36
|
+
) -> HTTPTransport:
|
|
37
|
+
"""Create an HTTP transport using the cloud API.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
client: Authenticated LuxpowerClient instance
|
|
41
|
+
serial: Inverter serial number
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
HTTPTransport instance ready for use
|
|
45
|
+
|
|
46
|
+
Example:
|
|
47
|
+
async with LuxpowerClient(username, password) as client:
|
|
48
|
+
transport = create_http_transport(client, "CE12345678")
|
|
49
|
+
await transport.connect()
|
|
50
|
+
|
|
51
|
+
runtime = await transport.read_runtime()
|
|
52
|
+
print(f"PV Power: {runtime.pv_total_power}W")
|
|
53
|
+
print(f"Battery SOC: {runtime.battery_soc}%")
|
|
54
|
+
|
|
55
|
+
energy = await transport.read_energy()
|
|
56
|
+
print(f"Today's yield: {energy.pv_energy_today} kWh")
|
|
57
|
+
"""
|
|
58
|
+
return HTTPTransport(client, serial)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def create_modbus_transport(
|
|
62
|
+
host: str,
|
|
63
|
+
serial: str,
|
|
64
|
+
*,
|
|
65
|
+
port: int = 502,
|
|
66
|
+
unit_id: int = 1,
|
|
67
|
+
timeout: float = 10.0,
|
|
68
|
+
) -> ModbusTransport:
|
|
69
|
+
"""Create a Modbus TCP transport for local network communication.
|
|
70
|
+
|
|
71
|
+
This allows direct communication with the inverter over the local network
|
|
72
|
+
without requiring cloud connectivity.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
host: Inverter IP address or hostname
|
|
76
|
+
serial: Inverter serial number (for identification)
|
|
77
|
+
port: Modbus TCP port (default: 502)
|
|
78
|
+
unit_id: Modbus unit/slave ID (default: 1)
|
|
79
|
+
timeout: Operation timeout in seconds (default: 10.0)
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
ModbusTransport instance ready for use
|
|
83
|
+
|
|
84
|
+
Example:
|
|
85
|
+
transport = create_modbus_transport(
|
|
86
|
+
host="192.168.1.100",
|
|
87
|
+
serial="CE12345678",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
async with transport:
|
|
91
|
+
runtime = await transport.read_runtime()
|
|
92
|
+
print(f"PV Power: {runtime.pv_total_power}W")
|
|
93
|
+
|
|
94
|
+
battery = await transport.read_battery()
|
|
95
|
+
if battery:
|
|
96
|
+
print(f"Battery SOC: {battery.soc}%")
|
|
97
|
+
|
|
98
|
+
Note:
|
|
99
|
+
Modbus communication requires:
|
|
100
|
+
- Network access to the inverter
|
|
101
|
+
- Modbus TCP enabled on the inverter (check inverter settings)
|
|
102
|
+
- No firewall blocking port 502
|
|
103
|
+
|
|
104
|
+
The inverter must have a datalogger/dongle that supports Modbus TCP,
|
|
105
|
+
or direct Modbus TCP capability (varies by model).
|
|
106
|
+
"""
|
|
107
|
+
return ModbusTransport(
|
|
108
|
+
host=host,
|
|
109
|
+
serial=serial,
|
|
110
|
+
port=port,
|
|
111
|
+
unit_id=unit_id,
|
|
112
|
+
timeout=timeout,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
__all__ = [
|
|
117
|
+
"create_http_transport",
|
|
118
|
+
"create_modbus_transport",
|
|
119
|
+
]
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""HTTP transport implementation.
|
|
2
|
+
|
|
3
|
+
This module provides the HTTPTransport class that wraps the existing
|
|
4
|
+
LuxpowerClient for cloud API communication.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from pylxpweb.exceptions import (
|
|
13
|
+
LuxpowerAPIError,
|
|
14
|
+
LuxpowerAuthError,
|
|
15
|
+
LuxpowerConnectionError,
|
|
16
|
+
LuxpowerDeviceError,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from .capabilities import HTTP_CAPABILITIES, TransportCapabilities
|
|
20
|
+
from .data import BatteryBankData, BatteryData, InverterEnergyData, InverterRuntimeData
|
|
21
|
+
from .exceptions import (
|
|
22
|
+
TransportConnectionError,
|
|
23
|
+
TransportReadError,
|
|
24
|
+
TransportTimeoutError,
|
|
25
|
+
TransportWriteError,
|
|
26
|
+
)
|
|
27
|
+
from .protocol import BaseTransport
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from pylxpweb import LuxpowerClient
|
|
31
|
+
|
|
32
|
+
_LOGGER = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class HTTPTransport(BaseTransport):
|
|
36
|
+
"""HTTP transport using cloud API via LuxpowerClient.
|
|
37
|
+
|
|
38
|
+
This transport wraps the existing LuxpowerClient to provide
|
|
39
|
+
the standard InverterTransport interface.
|
|
40
|
+
|
|
41
|
+
Example:
|
|
42
|
+
async with LuxpowerClient(username, password) as client:
|
|
43
|
+
transport = HTTPTransport(client, serial="CE12345678")
|
|
44
|
+
await transport.connect()
|
|
45
|
+
|
|
46
|
+
runtime = await transport.read_runtime()
|
|
47
|
+
print(f"PV Power: {runtime.pv_total_power}W")
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, client: LuxpowerClient, serial: str) -> None:
|
|
51
|
+
"""Initialize HTTP transport.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
client: Connected LuxpowerClient instance
|
|
55
|
+
serial: Inverter serial number
|
|
56
|
+
"""
|
|
57
|
+
super().__init__(serial)
|
|
58
|
+
self._client = client
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def capabilities(self) -> TransportCapabilities:
|
|
62
|
+
"""Get HTTP transport capabilities."""
|
|
63
|
+
return HTTP_CAPABILITIES
|
|
64
|
+
|
|
65
|
+
async def connect(self) -> None:
|
|
66
|
+
"""Verify connection to cloud API.
|
|
67
|
+
|
|
68
|
+
The LuxpowerClient handles actual authentication.
|
|
69
|
+
This method ensures the client session is valid.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
TransportConnectionError: If client not authenticated
|
|
73
|
+
"""
|
|
74
|
+
try:
|
|
75
|
+
# Ensure client is authenticated - login() handles session management
|
|
76
|
+
await self._client.login()
|
|
77
|
+
except LuxpowerAuthError as err:
|
|
78
|
+
_LOGGER.error("Authentication failed for %s: %s", self._serial, err)
|
|
79
|
+
raise TransportConnectionError(f"Authentication failed for cloud API: {err}") from err
|
|
80
|
+
except (TimeoutError, LuxpowerConnectionError, OSError) as err:
|
|
81
|
+
_LOGGER.error("Connection failed for %s: %s", self._serial, err)
|
|
82
|
+
raise TransportConnectionError(f"Failed to connect to cloud API: {err}") from err
|
|
83
|
+
|
|
84
|
+
self._connected = True
|
|
85
|
+
_LOGGER.debug("HTTP transport connected for %s", self._serial)
|
|
86
|
+
|
|
87
|
+
async def disconnect(self) -> None:
|
|
88
|
+
"""Mark transport as disconnected.
|
|
89
|
+
|
|
90
|
+
Note: Does not close the LuxpowerClient session, as it may be
|
|
91
|
+
shared across multiple transports.
|
|
92
|
+
"""
|
|
93
|
+
self._connected = False
|
|
94
|
+
_LOGGER.debug("HTTP transport disconnected for %s", self._serial)
|
|
95
|
+
|
|
96
|
+
async def read_runtime(self) -> InverterRuntimeData:
|
|
97
|
+
"""Read runtime data via HTTP API.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
Runtime data with all values properly scaled
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
TransportReadError: If API call fails
|
|
104
|
+
TransportTimeoutError: If request times out
|
|
105
|
+
"""
|
|
106
|
+
self._ensure_connected()
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
runtime = await self._client.api.devices.get_inverter_runtime(self._serial)
|
|
110
|
+
return InverterRuntimeData.from_http_response(runtime)
|
|
111
|
+
except TimeoutError as err:
|
|
112
|
+
_LOGGER.error("Timeout reading runtime data for %s", self._serial)
|
|
113
|
+
raise TransportTimeoutError(f"Timeout reading runtime data for {self._serial}") from err
|
|
114
|
+
except (LuxpowerAPIError, LuxpowerDeviceError, LuxpowerConnectionError) as err:
|
|
115
|
+
_LOGGER.error("Failed to read runtime data for %s: %s", self._serial, err)
|
|
116
|
+
raise TransportReadError(
|
|
117
|
+
f"Failed to read runtime data for {self._serial}: {err}"
|
|
118
|
+
) from err
|
|
119
|
+
|
|
120
|
+
async def read_energy(self) -> InverterEnergyData:
|
|
121
|
+
"""Read energy statistics via HTTP API.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
Energy data with all values in kWh
|
|
125
|
+
|
|
126
|
+
Raises:
|
|
127
|
+
TransportReadError: If API call fails
|
|
128
|
+
TransportTimeoutError: If request times out
|
|
129
|
+
"""
|
|
130
|
+
self._ensure_connected()
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
energy = await self._client.api.devices.get_inverter_energy(self._serial)
|
|
134
|
+
return InverterEnergyData.from_http_response(energy)
|
|
135
|
+
except TimeoutError as err:
|
|
136
|
+
_LOGGER.error("Timeout reading energy data for %s", self._serial)
|
|
137
|
+
raise TransportTimeoutError(f"Timeout reading energy data for {self._serial}") from err
|
|
138
|
+
except (LuxpowerAPIError, LuxpowerDeviceError, LuxpowerConnectionError) as err:
|
|
139
|
+
_LOGGER.error("Failed to read energy data for %s: %s", self._serial, err)
|
|
140
|
+
raise TransportReadError(
|
|
141
|
+
f"Failed to read energy data for {self._serial}: {err}"
|
|
142
|
+
) from err
|
|
143
|
+
|
|
144
|
+
async def read_battery(self) -> BatteryBankData | None:
|
|
145
|
+
"""Read battery information via HTTP API.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Battery bank data if batteries present, None otherwise
|
|
149
|
+
|
|
150
|
+
Raises:
|
|
151
|
+
TransportReadError: If API call fails
|
|
152
|
+
"""
|
|
153
|
+
self._ensure_connected()
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
battery_info = await self._client.api.devices.get_battery_info(self._serial)
|
|
157
|
+
|
|
158
|
+
if battery_info is None:
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
# Import scaling here to avoid circular imports
|
|
162
|
+
from pylxpweb.constants.scaling import ScaleFactor, apply_scale
|
|
163
|
+
|
|
164
|
+
# Build individual battery data from batteryArray
|
|
165
|
+
batteries: list[BatteryData] = []
|
|
166
|
+
if battery_info.batteryArray:
|
|
167
|
+
for bat in battery_info.batteryArray:
|
|
168
|
+
# BatteryModule fields: batIndex, batterySn, totalVoltage, current, etc.
|
|
169
|
+
batteries.append(
|
|
170
|
+
BatteryData(
|
|
171
|
+
battery_index=bat.batIndex,
|
|
172
|
+
serial_number=bat.batterySn or "",
|
|
173
|
+
# totalVoltage needs /100 scaling
|
|
174
|
+
voltage=apply_scale(bat.totalVoltage, ScaleFactor.SCALE_100),
|
|
175
|
+
# current needs /10 scaling (not /100!)
|
|
176
|
+
current=apply_scale(bat.current, ScaleFactor.SCALE_10),
|
|
177
|
+
soc=bat.soc or 0,
|
|
178
|
+
soh=bat.soh or 100,
|
|
179
|
+
# Temperatures: batMaxCellTemp/batMinCellTemp are /10
|
|
180
|
+
temperature=apply_scale(bat.batMaxCellTemp, ScaleFactor.SCALE_10),
|
|
181
|
+
max_capacity=float(bat.currentFullCapacity or 0),
|
|
182
|
+
current_capacity=float(bat.currentRemainCapacity or 0),
|
|
183
|
+
cycle_count=bat.cycleCnt or 0,
|
|
184
|
+
# Cell voltage extremes: /1000 scaling
|
|
185
|
+
min_cell_voltage=apply_scale(
|
|
186
|
+
bat.batMinCellVoltage, ScaleFactor.SCALE_1000
|
|
187
|
+
),
|
|
188
|
+
max_cell_voltage=apply_scale(
|
|
189
|
+
bat.batMaxCellVoltage, ScaleFactor.SCALE_1000
|
|
190
|
+
),
|
|
191
|
+
# BatteryModule doesn't have status/fault/warning codes
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# Build aggregate bank data from BatteryInfo header
|
|
196
|
+
# BatteryInfo fields: vBat (/10), soc, pCharge, pDisCharge, etc.
|
|
197
|
+
return BatteryBankData(
|
|
198
|
+
voltage=apply_scale(battery_info.vBat, ScaleFactor.SCALE_10),
|
|
199
|
+
soc=battery_info.soc or 0,
|
|
200
|
+
charge_power=float(battery_info.pCharge or 0),
|
|
201
|
+
discharge_power=float(battery_info.pDisCharge or 0),
|
|
202
|
+
max_capacity=float(battery_info.maxBatteryCharge or 0),
|
|
203
|
+
current_capacity=float(battery_info.currentBatteryCharge or 0),
|
|
204
|
+
battery_count=battery_info.totalNumber or len(batteries),
|
|
205
|
+
batteries=batteries,
|
|
206
|
+
# Note: BatteryInfo doesn't have soh, temperature, current, status codes
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
except TimeoutError as err:
|
|
210
|
+
_LOGGER.error("Timeout reading battery data for %s", self._serial)
|
|
211
|
+
raise TransportTimeoutError(f"Timeout reading battery data for {self._serial}") from err
|
|
212
|
+
except (LuxpowerAPIError, LuxpowerDeviceError, LuxpowerConnectionError) as err:
|
|
213
|
+
_LOGGER.error("Failed to read battery data for %s: %s", self._serial, err)
|
|
214
|
+
raise TransportReadError(
|
|
215
|
+
f"Failed to read battery data for {self._serial}: {err}"
|
|
216
|
+
) from err
|
|
217
|
+
|
|
218
|
+
async def read_parameters(
|
|
219
|
+
self,
|
|
220
|
+
start_address: int,
|
|
221
|
+
count: int,
|
|
222
|
+
) -> dict[int, int]:
|
|
223
|
+
"""Read configuration parameters via HTTP API.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
start_address: Starting register address
|
|
227
|
+
count: Number of registers to read (max 127)
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
Dict mapping register address to raw integer value
|
|
231
|
+
|
|
232
|
+
Raises:
|
|
233
|
+
TransportReadError: If API call fails
|
|
234
|
+
TransportTimeoutError: If request times out
|
|
235
|
+
"""
|
|
236
|
+
self._ensure_connected()
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
response = await self._client.api.control.read_parameters(
|
|
240
|
+
self._serial,
|
|
241
|
+
start_register=start_address,
|
|
242
|
+
point_number=min(count, 127), # API limit
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
# ParameterReadResponse has a .parameters property that extracts
|
|
246
|
+
# the register values as a dict (excluding metadata fields)
|
|
247
|
+
params = response.parameters
|
|
248
|
+
|
|
249
|
+
# Convert response to address -> value dict
|
|
250
|
+
# The API returns parameter names, we need to map back to addresses
|
|
251
|
+
result: dict[int, int] = {}
|
|
252
|
+
for key, value in params.items():
|
|
253
|
+
try:
|
|
254
|
+
# Try to extract register address from various formats
|
|
255
|
+
if isinstance(value, int):
|
|
256
|
+
# Value is the register value, key might be the address
|
|
257
|
+
if key.isdigit():
|
|
258
|
+
result[int(key)] = value
|
|
259
|
+
else:
|
|
260
|
+
# Key is parameter name, need reverse lookup
|
|
261
|
+
# For now, skip named parameters (they need mapping)
|
|
262
|
+
_LOGGER.debug(
|
|
263
|
+
"Skipping named parameter %s=%s (address lookup not implemented)",
|
|
264
|
+
key,
|
|
265
|
+
value,
|
|
266
|
+
)
|
|
267
|
+
elif isinstance(value, (str, float)) and key.isdigit():
|
|
268
|
+
result[int(key)] = int(float(value))
|
|
269
|
+
else:
|
|
270
|
+
_LOGGER.debug(
|
|
271
|
+
"Skipping parameter with unexpected format: key=%s, value=%s (type=%s)",
|
|
272
|
+
key,
|
|
273
|
+
value,
|
|
274
|
+
type(value).__name__,
|
|
275
|
+
)
|
|
276
|
+
except (ValueError, TypeError) as err:
|
|
277
|
+
_LOGGER.warning(
|
|
278
|
+
"Failed to parse parameter %s=%s: %s",
|
|
279
|
+
key,
|
|
280
|
+
value,
|
|
281
|
+
err,
|
|
282
|
+
)
|
|
283
|
+
continue
|
|
284
|
+
|
|
285
|
+
return result
|
|
286
|
+
|
|
287
|
+
except TimeoutError as err:
|
|
288
|
+
_LOGGER.error("Timeout reading parameters for %s", self._serial)
|
|
289
|
+
raise TransportTimeoutError(f"Timeout reading parameters for {self._serial}") from err
|
|
290
|
+
except (LuxpowerAPIError, LuxpowerDeviceError, LuxpowerConnectionError) as err:
|
|
291
|
+
_LOGGER.error("Failed to read parameters for %s: %s", self._serial, err)
|
|
292
|
+
raise TransportReadError(
|
|
293
|
+
f"Failed to read parameters for {self._serial}: {err}"
|
|
294
|
+
) from err
|
|
295
|
+
|
|
296
|
+
async def write_parameters(
|
|
297
|
+
self,
|
|
298
|
+
parameters: dict[int, int],
|
|
299
|
+
) -> bool:
|
|
300
|
+
"""Write configuration parameters via HTTP API.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
parameters: Dict mapping register address to value to write
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
True if write succeeded
|
|
307
|
+
|
|
308
|
+
Raises:
|
|
309
|
+
TransportWriteError: If API call fails
|
|
310
|
+
TransportTimeoutError: If request times out
|
|
311
|
+
"""
|
|
312
|
+
self._ensure_connected()
|
|
313
|
+
|
|
314
|
+
try:
|
|
315
|
+
# Use the batch write_parameters method which takes dict[int, int]
|
|
316
|
+
await self._client.api.control.write_parameters(
|
|
317
|
+
self._serial,
|
|
318
|
+
parameters=parameters,
|
|
319
|
+
)
|
|
320
|
+
return True
|
|
321
|
+
|
|
322
|
+
except TimeoutError as err:
|
|
323
|
+
_LOGGER.error("Timeout writing parameters for %s", self._serial)
|
|
324
|
+
raise TransportTimeoutError(f"Timeout writing parameters for {self._serial}") from err
|
|
325
|
+
except (LuxpowerAPIError, LuxpowerDeviceError, LuxpowerConnectionError) as err:
|
|
326
|
+
_LOGGER.error("Failed to write parameters for %s: %s", self._serial, err)
|
|
327
|
+
raise TransportWriteError(
|
|
328
|
+
f"Failed to write parameters for {self._serial}: {err}"
|
|
329
|
+
) from err
|