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
@@ -0,0 +1,78 @@
1
+ """Transport layer abstraction for pylxpweb.
2
+
3
+ This module provides transport-agnostic communication with inverters,
4
+ supporting both cloud HTTP API and local Modbus TCP connections.
5
+
6
+ Usage:
7
+ # HTTP Transport (cloud API) - using factory function
8
+ from pylxpweb.transports import create_http_transport
9
+
10
+ async with LuxpowerClient(username, password) as client:
11
+ transport = create_http_transport(client, serial="CE12345678")
12
+ await transport.connect()
13
+ runtime = await transport.read_runtime()
14
+
15
+ # Modbus Transport (local) - using factory function
16
+ from pylxpweb.transports import create_modbus_transport
17
+
18
+ transport = create_modbus_transport(
19
+ host="192.168.1.100",
20
+ serial="CE12345678",
21
+ )
22
+ async with transport:
23
+ runtime = await transport.read_runtime() # Same interface!
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from .capabilities import (
29
+ HTTP_CAPABILITIES,
30
+ MODBUS_CAPABILITIES,
31
+ TransportCapabilities,
32
+ )
33
+ from .data import (
34
+ BatteryBankData,
35
+ BatteryData,
36
+ InverterEnergyData,
37
+ InverterRuntimeData,
38
+ )
39
+ from .exceptions import (
40
+ TransportConnectionError,
41
+ TransportError,
42
+ TransportReadError,
43
+ TransportTimeoutError,
44
+ TransportWriteError,
45
+ UnsupportedOperationError,
46
+ )
47
+ from .factory import create_http_transport, create_modbus_transport
48
+ from .http import HTTPTransport
49
+ from .modbus import ModbusTransport
50
+ from .protocol import BaseTransport, InverterTransport
51
+
52
+ __all__ = [
53
+ # Factory functions (recommended)
54
+ "create_http_transport",
55
+ "create_modbus_transport",
56
+ # Protocol
57
+ "InverterTransport",
58
+ "BaseTransport",
59
+ # Transport implementations
60
+ "HTTPTransport",
61
+ "ModbusTransport",
62
+ # Capabilities
63
+ "TransportCapabilities",
64
+ "HTTP_CAPABILITIES",
65
+ "MODBUS_CAPABILITIES",
66
+ # Data models
67
+ "InverterRuntimeData",
68
+ "InverterEnergyData",
69
+ "BatteryBankData",
70
+ "BatteryData",
71
+ # Exceptions
72
+ "TransportError",
73
+ "TransportConnectionError",
74
+ "TransportTimeoutError",
75
+ "TransportReadError",
76
+ "TransportWriteError",
77
+ "UnsupportedOperationError",
78
+ ]
@@ -0,0 +1,101 @@
1
+ """Transport capabilities definition.
2
+
3
+ This module defines what operations each transport type can perform,
4
+ allowing clients to check capabilities before attempting operations.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class TransportCapabilities:
14
+ """Defines what operations a transport can perform.
15
+
16
+ Clients can check these capabilities to gracefully handle
17
+ feature differences between HTTP and Modbus transports.
18
+
19
+ Attributes:
20
+ can_read_runtime: Can read real-time inverter data
21
+ can_read_energy: Can read energy statistics
22
+ can_read_battery: Can read battery bank information
23
+ can_read_parameters: Can read configuration registers
24
+ can_write_parameters: Can write configuration registers
25
+ can_discover_devices: Can discover devices (HTTP only)
26
+ can_read_history: Can read historical data (HTTP only)
27
+ can_read_analytics: Can read analytics data (HTTP only)
28
+ can_trigger_firmware_update: Can trigger firmware updates (HTTP only)
29
+ can_read_parallel_group_energy: Can read parallel group totals (HTTP only)
30
+ min_poll_interval_seconds: Minimum recommended polling interval
31
+ supports_concurrent_reads: Can perform concurrent register reads
32
+ requires_authentication: Requires login/authentication
33
+ is_local: Operates on local network (no cloud dependency)
34
+ """
35
+
36
+ # Core data access
37
+ can_read_runtime: bool = True
38
+ can_read_energy: bool = True
39
+ can_read_battery: bool = True
40
+ can_read_parameters: bool = True
41
+ can_write_parameters: bool = True
42
+
43
+ # Cloud-only features
44
+ can_discover_devices: bool = False
45
+ can_read_history: bool = False
46
+ can_read_analytics: bool = False
47
+ can_trigger_firmware_update: bool = False
48
+ can_read_parallel_group_energy: bool = False
49
+
50
+ # Performance characteristics
51
+ min_poll_interval_seconds: float = 1.0
52
+ supports_concurrent_reads: bool = True
53
+
54
+ # Connection characteristics
55
+ requires_authentication: bool = False
56
+ is_local: bool = False
57
+
58
+
59
+ # Predefined capability sets for each transport type
60
+
61
+ HTTP_CAPABILITIES = TransportCapabilities(
62
+ # Core access - all supported
63
+ can_read_runtime=True,
64
+ can_read_energy=True,
65
+ can_read_battery=True,
66
+ can_read_parameters=True,
67
+ can_write_parameters=True,
68
+ # Cloud features - all supported
69
+ can_discover_devices=True,
70
+ can_read_history=True,
71
+ can_read_analytics=True,
72
+ can_trigger_firmware_update=True,
73
+ can_read_parallel_group_energy=True,
74
+ # Performance - rate limited
75
+ min_poll_interval_seconds=30.0, # Cloud API rate limiting
76
+ supports_concurrent_reads=True,
77
+ # Connection
78
+ requires_authentication=True,
79
+ is_local=False,
80
+ )
81
+
82
+ MODBUS_CAPABILITIES = TransportCapabilities(
83
+ # Core access - all supported
84
+ can_read_runtime=True,
85
+ can_read_energy=True, # Via input registers
86
+ can_read_battery=True, # Via input registers
87
+ can_read_parameters=True,
88
+ can_write_parameters=True,
89
+ # Cloud features - not available
90
+ can_discover_devices=False,
91
+ can_read_history=False,
92
+ can_read_analytics=False,
93
+ can_trigger_firmware_update=False,
94
+ can_read_parallel_group_energy=False,
95
+ # Performance - no rate limiting
96
+ min_poll_interval_seconds=1.0, # Local network, minimal delay
97
+ supports_concurrent_reads=True,
98
+ # Connection
99
+ requires_authentication=False,
100
+ is_local=True,
101
+ )
@@ -0,0 +1,495 @@
1
+ """Transport-agnostic data models.
2
+
3
+ This module provides data classes that represent inverter data
4
+ in a transport-agnostic way. Both HTTP and Modbus transports
5
+ produce these same data structures with scaling already applied.
6
+
7
+ All values are in standard units:
8
+ - Voltage: Volts (V)
9
+ - Current: Amperes (A)
10
+ - Power: Watts (W)
11
+ - Energy: Watt-hours (Wh) or Kilowatt-hours (kWh) as noted
12
+ - Temperature: Celsius (°C)
13
+ - Frequency: Hertz (Hz)
14
+ - Percentage: 0-100 (%)
15
+
16
+ Data classes include validation in __post_init__ to clamp percentage
17
+ values (SOC, SOH) to valid 0-100 range and log warnings for out-of-range values.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ from dataclasses import dataclass, field
24
+ from datetime import datetime
25
+ from typing import TYPE_CHECKING
26
+
27
+ if TYPE_CHECKING:
28
+ from pylxpweb.models import EnergyInfo, InverterRuntime
29
+
30
+ _LOGGER = logging.getLogger(__name__)
31
+
32
+
33
+ def _clamp_percentage(value: int, name: str) -> int:
34
+ """Clamp percentage value to 0-100 range, logging if out of bounds."""
35
+ if value < 0:
36
+ _LOGGER.warning("%s value %d is negative, clamping to 0", name, value)
37
+ return 0
38
+ if value > 100:
39
+ _LOGGER.warning("%s value %d exceeds 100%%, clamping to 100", name, value)
40
+ return 100
41
+ return value
42
+
43
+
44
+ @dataclass
45
+ class InverterRuntimeData:
46
+ """Real-time inverter operating data.
47
+
48
+ All values are already scaled to proper units.
49
+ This is the transport-agnostic representation of runtime data.
50
+
51
+ Validation:
52
+ - battery_soc and battery_soh are clamped to 0-100 range
53
+ """
54
+
55
+ # Timestamp
56
+ timestamp: datetime = field(default_factory=datetime.now)
57
+
58
+ # PV Input
59
+ pv1_voltage: float = 0.0 # V
60
+ pv1_current: float = 0.0 # A
61
+ pv1_power: float = 0.0 # W
62
+ pv2_voltage: float = 0.0 # V
63
+ pv2_current: float = 0.0 # A
64
+ pv2_power: float = 0.0 # W
65
+ pv3_voltage: float = 0.0 # V
66
+ pv3_current: float = 0.0 # A
67
+ pv3_power: float = 0.0 # W
68
+ pv_total_power: float = 0.0 # W
69
+
70
+ # Battery
71
+ battery_voltage: float = 0.0 # V
72
+ battery_current: float = 0.0 # A
73
+ battery_soc: int = 0 # %
74
+ battery_soh: int = 100 # %
75
+ battery_charge_power: float = 0.0 # W
76
+ battery_discharge_power: float = 0.0 # W
77
+ battery_temperature: float = 0.0 # °C
78
+
79
+ # Grid (AC Input)
80
+ grid_voltage_r: float = 0.0 # V (Phase R/L1)
81
+ grid_voltage_s: float = 0.0 # V (Phase S/L2)
82
+ grid_voltage_t: float = 0.0 # V (Phase T/L3)
83
+ grid_current_r: float = 0.0 # A
84
+ grid_current_s: float = 0.0 # A
85
+ grid_current_t: float = 0.0 # A
86
+ grid_frequency: float = 0.0 # Hz
87
+ grid_power: float = 0.0 # W (positive = import, negative = export)
88
+ power_to_grid: float = 0.0 # W (export)
89
+ power_from_grid: float = 0.0 # W (import)
90
+
91
+ # Inverter Output
92
+ inverter_power: float = 0.0 # W
93
+ inverter_current_r: float = 0.0 # A
94
+ inverter_current_s: float = 0.0 # A
95
+ inverter_current_t: float = 0.0 # A
96
+ power_factor: float = 1.0 # 0.0-1.0
97
+
98
+ # EPS/Off-Grid Output
99
+ eps_voltage_r: float = 0.0 # V
100
+ eps_voltage_s: float = 0.0 # V
101
+ eps_voltage_t: float = 0.0 # V
102
+ eps_frequency: float = 0.0 # Hz
103
+ eps_power: float = 0.0 # W
104
+ eps_status: int = 0 # Status code
105
+
106
+ # Load
107
+ load_power: float = 0.0 # W
108
+
109
+ # Internal
110
+ bus_voltage_1: float = 0.0 # V
111
+ bus_voltage_2: float = 0.0 # V
112
+
113
+ # Temperatures
114
+ internal_temperature: float = 0.0 # °C
115
+ radiator_temperature_1: float = 0.0 # °C
116
+ radiator_temperature_2: float = 0.0 # °C
117
+ battery_control_temperature: float = 0.0 # °C
118
+
119
+ # Status
120
+ device_status: int = 0 # Status code
121
+ fault_code: int = 0 # Fault code
122
+ warning_code: int = 0 # Warning code
123
+
124
+ def __post_init__(self) -> None:
125
+ """Validate and clamp percentage values."""
126
+ self.battery_soc = _clamp_percentage(self.battery_soc, "battery_soc")
127
+ self.battery_soh = _clamp_percentage(self.battery_soh, "battery_soh")
128
+
129
+ @classmethod
130
+ def from_http_response(cls, runtime: InverterRuntime) -> InverterRuntimeData:
131
+ """Create from HTTP API InverterRuntime response.
132
+
133
+ Args:
134
+ runtime: Pydantic model from HTTP API
135
+
136
+ Returns:
137
+ Transport-agnostic runtime data with scaling applied
138
+ """
139
+ # Import scaling functions
140
+ from pylxpweb.constants.scaling import scale_runtime_value
141
+
142
+ return cls(
143
+ timestamp=datetime.now(),
144
+ # PV - API returns values needing /10 scaling
145
+ pv1_voltage=scale_runtime_value("vpv1", runtime.vpv1),
146
+ pv1_power=float(runtime.ppv1 or 0),
147
+ pv2_voltage=scale_runtime_value("vpv2", runtime.vpv2),
148
+ pv2_power=float(runtime.ppv2 or 0),
149
+ pv3_voltage=scale_runtime_value("vpv3", runtime.vpv3 or 0),
150
+ pv3_power=float(runtime.ppv3 or 0),
151
+ pv_total_power=float(runtime.ppv or 0),
152
+ # Battery
153
+ battery_voltage=scale_runtime_value("vBat", runtime.vBat),
154
+ battery_soc=runtime.soc or 0,
155
+ battery_charge_power=float(runtime.pCharge or 0),
156
+ battery_discharge_power=float(runtime.pDisCharge or 0),
157
+ battery_temperature=float(runtime.tBat or 0),
158
+ # Grid
159
+ grid_voltage_r=scale_runtime_value("vacr", runtime.vacr),
160
+ grid_voltage_s=scale_runtime_value("vacs", runtime.vacs),
161
+ grid_voltage_t=scale_runtime_value("vact", runtime.vact),
162
+ grid_frequency=scale_runtime_value("fac", runtime.fac),
163
+ grid_power=float(runtime.prec or 0),
164
+ power_to_grid=float(runtime.pToGrid or 0),
165
+ power_from_grid=float(runtime.prec or 0),
166
+ # Inverter
167
+ inverter_power=float(runtime.pinv or 0),
168
+ # EPS
169
+ eps_voltage_r=scale_runtime_value("vepsr", runtime.vepsr),
170
+ eps_voltage_s=scale_runtime_value("vepss", runtime.vepss),
171
+ eps_voltage_t=scale_runtime_value("vepst", runtime.vepst),
172
+ eps_frequency=scale_runtime_value("feps", runtime.feps),
173
+ eps_power=float(runtime.peps or 0),
174
+ eps_status=runtime.seps or 0,
175
+ # Load
176
+ load_power=float(runtime.pToUser or 0),
177
+ # Internal
178
+ bus_voltage_1=scale_runtime_value("vBus1", runtime.vBus1),
179
+ bus_voltage_2=scale_runtime_value("vBus2", runtime.vBus2),
180
+ # Temperatures
181
+ internal_temperature=float(runtime.tinner or 0),
182
+ radiator_temperature_1=float(runtime.tradiator1 or 0),
183
+ radiator_temperature_2=float(runtime.tradiator2 or 0),
184
+ # Status
185
+ device_status=runtime.status or 0,
186
+ # Note: InverterRuntime doesn't have faultCode/warningCode fields
187
+ )
188
+
189
+ @classmethod
190
+ def from_modbus_registers(
191
+ cls,
192
+ input_registers: dict[int, int],
193
+ ) -> InverterRuntimeData:
194
+ """Create from Modbus input register values.
195
+
196
+ Args:
197
+ input_registers: Dict mapping register address to raw value
198
+
199
+ Returns:
200
+ Transport-agnostic runtime data with scaling applied
201
+ """
202
+ from pylxpweb.constants.scaling import ScaleFactor, apply_scale
203
+
204
+ def get_reg(addr: int, default: int = 0) -> int:
205
+ """Get register value with default."""
206
+ return input_registers.get(addr, default)
207
+
208
+ def get_reg_pair(high_addr: int, low_addr: int) -> int:
209
+ """Get 32-bit value from register pair (high, low)."""
210
+ high = get_reg(high_addr)
211
+ low = get_reg(low_addr)
212
+ return (high << 16) | low
213
+
214
+ # Get power values from register pairs
215
+ pv1_power = get_reg_pair(6, 7)
216
+ pv2_power = get_reg_pair(8, 9)
217
+ pv3_power = get_reg_pair(10, 11)
218
+ charge_power = get_reg_pair(12, 13)
219
+ discharge_power = get_reg_pair(14, 15)
220
+ inverter_power = get_reg_pair(20, 21)
221
+ grid_power = get_reg_pair(22, 23)
222
+ eps_power = get_reg_pair(30, 31)
223
+ load_power = get_reg_pair(34, 35)
224
+
225
+ return cls(
226
+ timestamp=datetime.now(),
227
+ # PV
228
+ pv1_voltage=apply_scale(get_reg(1), ScaleFactor.SCALE_10),
229
+ pv1_power=float(pv1_power),
230
+ pv2_voltage=apply_scale(get_reg(2), ScaleFactor.SCALE_10),
231
+ pv2_power=float(pv2_power),
232
+ pv3_voltage=apply_scale(get_reg(3), ScaleFactor.SCALE_10),
233
+ pv3_power=float(pv3_power),
234
+ pv_total_power=float(pv1_power + pv2_power + pv3_power),
235
+ # Battery
236
+ battery_voltage=apply_scale(get_reg(4), ScaleFactor.SCALE_100),
237
+ battery_soc=get_reg(5),
238
+ battery_charge_power=float(charge_power),
239
+ battery_discharge_power=float(discharge_power),
240
+ battery_temperature=float(get_reg(64)),
241
+ # Grid
242
+ grid_voltage_r=apply_scale(get_reg(16), ScaleFactor.SCALE_10),
243
+ grid_voltage_s=apply_scale(get_reg(17), ScaleFactor.SCALE_10),
244
+ grid_voltage_t=apply_scale(get_reg(18), ScaleFactor.SCALE_10),
245
+ grid_frequency=apply_scale(get_reg(19), ScaleFactor.SCALE_100),
246
+ grid_power=float(grid_power),
247
+ power_to_grid=float(get_reg(33)),
248
+ power_from_grid=float(grid_power),
249
+ # Inverter
250
+ inverter_power=float(inverter_power),
251
+ # EPS
252
+ eps_voltage_r=apply_scale(get_reg(26), ScaleFactor.SCALE_10),
253
+ eps_voltage_s=apply_scale(get_reg(27), ScaleFactor.SCALE_10),
254
+ eps_voltage_t=apply_scale(get_reg(28), ScaleFactor.SCALE_10),
255
+ eps_frequency=apply_scale(get_reg(29), ScaleFactor.SCALE_100),
256
+ eps_power=float(eps_power),
257
+ eps_status=get_reg(32),
258
+ # Load
259
+ load_power=float(load_power),
260
+ # Internal
261
+ bus_voltage_1=apply_scale(get_reg(43), ScaleFactor.SCALE_10),
262
+ bus_voltage_2=apply_scale(get_reg(44), ScaleFactor.SCALE_10),
263
+ # Temperatures
264
+ internal_temperature=float(get_reg(61)),
265
+ radiator_temperature_1=float(get_reg(62)),
266
+ radiator_temperature_2=float(get_reg(63)),
267
+ # Status
268
+ device_status=get_reg(0),
269
+ )
270
+
271
+
272
+ @dataclass
273
+ class InverterEnergyData:
274
+ """Energy production and consumption statistics.
275
+
276
+ All values are already scaled to proper units.
277
+ """
278
+
279
+ # Timestamp
280
+ timestamp: datetime = field(default_factory=datetime.now)
281
+
282
+ # Daily energy (kWh)
283
+ pv_energy_today: float = 0.0
284
+ pv1_energy_today: float = 0.0
285
+ pv2_energy_today: float = 0.0
286
+ pv3_energy_today: float = 0.0
287
+ charge_energy_today: float = 0.0
288
+ discharge_energy_today: float = 0.0
289
+ grid_import_today: float = 0.0
290
+ grid_export_today: float = 0.0
291
+ load_energy_today: float = 0.0
292
+ eps_energy_today: float = 0.0
293
+
294
+ # Lifetime energy (kWh)
295
+ pv_energy_total: float = 0.0
296
+ pv1_energy_total: float = 0.0
297
+ pv2_energy_total: float = 0.0
298
+ pv3_energy_total: float = 0.0
299
+ charge_energy_total: float = 0.0
300
+ discharge_energy_total: float = 0.0
301
+ grid_import_total: float = 0.0
302
+ grid_export_total: float = 0.0
303
+ load_energy_total: float = 0.0
304
+ eps_energy_total: float = 0.0
305
+
306
+ # Inverter output energy
307
+ inverter_energy_today: float = 0.0
308
+ inverter_energy_total: float = 0.0
309
+
310
+ @classmethod
311
+ def from_http_response(cls, energy: EnergyInfo) -> InverterEnergyData:
312
+ """Create from HTTP API EnergyInfo response.
313
+
314
+ Args:
315
+ energy: Pydantic model from HTTP API
316
+
317
+ Returns:
318
+ Transport-agnostic energy data with scaling applied
319
+
320
+ Note:
321
+ EnergyInfo uses naming convention like todayYielding, todayCharging, etc.
322
+ Values from API are in 0.1 kWh units, need /10 for kWh.
323
+ """
324
+ from pylxpweb.constants.scaling import scale_energy_value
325
+
326
+ return cls(
327
+ timestamp=datetime.now(),
328
+ # Daily - API returns 0.1 kWh units, scale to kWh
329
+ pv_energy_today=scale_energy_value("todayYielding", energy.todayYielding),
330
+ charge_energy_today=scale_energy_value("todayCharging", energy.todayCharging),
331
+ discharge_energy_today=scale_energy_value("todayDischarging", energy.todayDischarging),
332
+ grid_import_today=scale_energy_value("todayImport", energy.todayImport),
333
+ grid_export_today=scale_energy_value("todayExport", energy.todayExport),
334
+ load_energy_today=scale_energy_value("todayUsage", energy.todayUsage),
335
+ # Lifetime - API returns 0.1 kWh units, scale to kWh
336
+ pv_energy_total=scale_energy_value("totalYielding", energy.totalYielding),
337
+ charge_energy_total=scale_energy_value("totalCharging", energy.totalCharging),
338
+ discharge_energy_total=scale_energy_value("totalDischarging", energy.totalDischarging),
339
+ grid_import_total=scale_energy_value("totalImport", energy.totalImport),
340
+ grid_export_total=scale_energy_value("totalExport", energy.totalExport),
341
+ load_energy_total=scale_energy_value("totalUsage", energy.totalUsage),
342
+ # Note: EnergyInfo doesn't have per-PV-string or inverter/EPS energy
343
+ # fields - those would require different API endpoints
344
+ )
345
+
346
+ @classmethod
347
+ def from_modbus_registers(
348
+ cls,
349
+ input_registers: dict[int, int],
350
+ ) -> InverterEnergyData:
351
+ """Create from Modbus input register values.
352
+
353
+ Args:
354
+ input_registers: Dict mapping register address to raw value
355
+
356
+ Returns:
357
+ Transport-agnostic energy data with scaling applied
358
+ """
359
+ from pylxpweb.constants.scaling import ScaleFactor, apply_scale
360
+
361
+ def get_reg(addr: int, default: int = 0) -> int:
362
+ """Get register value with default."""
363
+ return input_registers.get(addr, default)
364
+
365
+ def get_reg_pair(high_addr: int, low_addr: int) -> int:
366
+ """Get 32-bit value from register pair."""
367
+ high = get_reg(high_addr)
368
+ low = get_reg(low_addr)
369
+ return (high << 16) | low
370
+
371
+ # Modbus energy values are in 0.1 Wh, convert to kWh
372
+ def to_kwh(raw_value: int) -> float:
373
+ """Convert raw register value (0.1 Wh units) to kWh.
374
+
375
+ Conversion: raw / 10 = Wh, then / 1000 = kWh
376
+ Example: 184000 -> 18400 Wh -> 18.4 kWh
377
+ """
378
+ return apply_scale(raw_value, ScaleFactor.SCALE_10) / 1000.0
379
+
380
+ return cls(
381
+ timestamp=datetime.now(),
382
+ # Daily energy from register pairs
383
+ inverter_energy_today=to_kwh(get_reg_pair(45, 46)),
384
+ grid_import_today=to_kwh(get_reg_pair(47, 48)),
385
+ charge_energy_today=to_kwh(get_reg_pair(49, 50)),
386
+ discharge_energy_today=to_kwh(get_reg_pair(51, 52)),
387
+ eps_energy_today=to_kwh(get_reg_pair(53, 54)),
388
+ grid_export_today=to_kwh(get_reg_pair(55, 56)),
389
+ load_energy_today=to_kwh(get_reg_pair(57, 58)),
390
+ pv1_energy_today=to_kwh(get_reg_pair(97, 98)),
391
+ pv2_energy_today=to_kwh(get_reg_pair(99, 100)),
392
+ pv3_energy_today=to_kwh(get_reg_pair(101, 102)),
393
+ # Lifetime energy
394
+ inverter_energy_total=to_kwh(get_reg(36) * 1000),
395
+ grid_import_total=to_kwh(get_reg(37) * 1000),
396
+ charge_energy_total=to_kwh(get_reg(38) * 1000),
397
+ discharge_energy_total=to_kwh(get_reg(39) * 1000),
398
+ eps_energy_total=to_kwh(get_reg(40) * 1000),
399
+ grid_export_total=to_kwh(get_reg(41) * 1000),
400
+ load_energy_total=to_kwh(get_reg(42) * 1000),
401
+ pv1_energy_total=to_kwh(get_reg_pair(91, 92)),
402
+ pv2_energy_total=to_kwh(get_reg_pair(93, 94)),
403
+ pv3_energy_total=to_kwh(get_reg_pair(95, 96)),
404
+ )
405
+
406
+
407
+ @dataclass
408
+ class BatteryData:
409
+ """Individual battery module data.
410
+
411
+ All values are already scaled to proper units.
412
+
413
+ Validation:
414
+ - soc and soh are clamped to 0-100 range
415
+ """
416
+
417
+ # Identity
418
+ battery_index: int = 0 # 0-based index in bank
419
+ serial_number: str = ""
420
+
421
+ # State
422
+ voltage: float = 0.0 # V
423
+ current: float = 0.0 # A
424
+ soc: int = 0 # %
425
+ soh: int = 100 # %
426
+ temperature: float = 0.0 # °C
427
+
428
+ # Capacity
429
+ max_capacity: float = 0.0 # Ah
430
+ current_capacity: float = 0.0 # Ah
431
+ cycle_count: int = 0
432
+
433
+ # Cell data (optional, if available)
434
+ cell_count: int = 0
435
+ cell_voltages: list[float] = field(default_factory=list) # V per cell
436
+ cell_temperatures: list[float] = field(default_factory=list) # °C per cell
437
+ min_cell_voltage: float = 0.0 # V
438
+ max_cell_voltage: float = 0.0 # V
439
+
440
+ # Status
441
+ status: int = 0
442
+ fault_code: int = 0
443
+ warning_code: int = 0
444
+
445
+ def __post_init__(self) -> None:
446
+ """Validate and clamp percentage values."""
447
+ self.soc = _clamp_percentage(self.soc, "battery_soc")
448
+ self.soh = _clamp_percentage(self.soh, "battery_soh")
449
+
450
+
451
+ @dataclass
452
+ class BatteryBankData:
453
+ """Aggregate battery bank data.
454
+
455
+ All values are already scaled to proper units.
456
+
457
+ Validation:
458
+ - soc and soh are clamped to 0-100 range
459
+
460
+ Note:
461
+ battery_count reflects the API-reported count and may differ from
462
+ len(batteries) if the API returns a different count than battery array size.
463
+ """
464
+
465
+ # Timestamp
466
+ timestamp: datetime = field(default_factory=datetime.now)
467
+
468
+ # Aggregate state
469
+ voltage: float = 0.0 # V
470
+ current: float = 0.0 # A
471
+ soc: int = 0 # %
472
+ soh: int = 100 # %
473
+ temperature: float = 0.0 # °C
474
+
475
+ # Power
476
+ charge_power: float = 0.0 # W
477
+ discharge_power: float = 0.0 # W
478
+
479
+ # Capacity
480
+ max_capacity: float = 0.0 # Ah
481
+ current_capacity: float = 0.0 # Ah
482
+
483
+ # Status
484
+ status: int = 0
485
+ fault_code: int = 0
486
+ warning_code: int = 0
487
+
488
+ # Individual batteries
489
+ battery_count: int = 0
490
+ batteries: list[BatteryData] = field(default_factory=list)
491
+
492
+ def __post_init__(self) -> None:
493
+ """Validate and clamp percentage values."""
494
+ self.soc = _clamp_percentage(self.soc, "battery_bank_soc")
495
+ self.soh = _clamp_percentage(self.soh, "battery_bank_soh")