pylxpweb 0.1.0__py3-none-any.whl → 0.5.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. pylxpweb/__init__.py +47 -2
  2. pylxpweb/api_namespace.py +241 -0
  3. pylxpweb/cli/__init__.py +3 -0
  4. pylxpweb/cli/collect_device_data.py +874 -0
  5. pylxpweb/client.py +387 -26
  6. pylxpweb/constants/__init__.py +481 -0
  7. pylxpweb/constants/api.py +48 -0
  8. pylxpweb/constants/devices.py +98 -0
  9. pylxpweb/constants/locations.py +227 -0
  10. pylxpweb/{constants.py → constants/registers.py} +72 -238
  11. pylxpweb/constants/scaling.py +479 -0
  12. pylxpweb/devices/__init__.py +32 -0
  13. pylxpweb/devices/_firmware_update_mixin.py +504 -0
  14. pylxpweb/devices/_mid_runtime_properties.py +1427 -0
  15. pylxpweb/devices/base.py +122 -0
  16. pylxpweb/devices/battery.py +589 -0
  17. pylxpweb/devices/battery_bank.py +331 -0
  18. pylxpweb/devices/inverters/__init__.py +32 -0
  19. pylxpweb/devices/inverters/_features.py +378 -0
  20. pylxpweb/devices/inverters/_runtime_properties.py +596 -0
  21. pylxpweb/devices/inverters/base.py +2124 -0
  22. pylxpweb/devices/inverters/generic.py +192 -0
  23. pylxpweb/devices/inverters/hybrid.py +274 -0
  24. pylxpweb/devices/mid_device.py +183 -0
  25. pylxpweb/devices/models.py +126 -0
  26. pylxpweb/devices/parallel_group.py +364 -0
  27. pylxpweb/devices/station.py +908 -0
  28. pylxpweb/endpoints/control.py +980 -2
  29. pylxpweb/endpoints/devices.py +249 -16
  30. pylxpweb/endpoints/firmware.py +43 -10
  31. pylxpweb/endpoints/plants.py +15 -19
  32. pylxpweb/exceptions.py +4 -0
  33. pylxpweb/models.py +708 -41
  34. pylxpweb/transports/__init__.py +78 -0
  35. pylxpweb/transports/capabilities.py +101 -0
  36. pylxpweb/transports/data.py +501 -0
  37. pylxpweb/transports/exceptions.py +59 -0
  38. pylxpweb/transports/factory.py +119 -0
  39. pylxpweb/transports/http.py +329 -0
  40. pylxpweb/transports/modbus.py +617 -0
  41. pylxpweb/transports/protocol.py +217 -0
  42. {pylxpweb-0.1.0.dist-info → pylxpweb-0.5.2.dist-info}/METADATA +130 -85
  43. pylxpweb-0.5.2.dist-info/RECORD +52 -0
  44. {pylxpweb-0.1.0.dist-info → pylxpweb-0.5.2.dist-info}/WHEEL +1 -1
  45. pylxpweb-0.5.2.dist-info/entry_points.txt +3 -0
  46. pylxpweb-0.1.0.dist-info/RECORD +0 -19
@@ -0,0 +1,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,501 @@
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_current=apply_scale(get_reg(75), ScaleFactor.SCALE_100), # Battery current (A)
238
+ battery_soc=get_reg(5),
239
+ battery_charge_power=float(charge_power),
240
+ battery_discharge_power=float(discharge_power),
241
+ battery_temperature=float(get_reg(64)),
242
+ # Grid
243
+ grid_voltage_r=apply_scale(get_reg(16), ScaleFactor.SCALE_10),
244
+ grid_voltage_s=apply_scale(get_reg(17), ScaleFactor.SCALE_10),
245
+ grid_voltage_t=apply_scale(get_reg(18), ScaleFactor.SCALE_10),
246
+ grid_frequency=apply_scale(get_reg(19), ScaleFactor.SCALE_100),
247
+ grid_power=float(grid_power),
248
+ power_to_grid=float(get_reg(33)),
249
+ power_from_grid=float(grid_power),
250
+ # Inverter
251
+ inverter_power=float(inverter_power),
252
+ # EPS
253
+ eps_voltage_r=apply_scale(get_reg(26), ScaleFactor.SCALE_10),
254
+ eps_voltage_s=apply_scale(get_reg(27), ScaleFactor.SCALE_10),
255
+ eps_voltage_t=apply_scale(get_reg(28), ScaleFactor.SCALE_10),
256
+ eps_frequency=apply_scale(get_reg(29), ScaleFactor.SCALE_100),
257
+ eps_power=float(eps_power),
258
+ eps_status=get_reg(32),
259
+ # Load
260
+ load_power=float(load_power),
261
+ # Internal
262
+ bus_voltage_1=apply_scale(get_reg(43), ScaleFactor.SCALE_10),
263
+ bus_voltage_2=apply_scale(get_reg(44), ScaleFactor.SCALE_10),
264
+ # Temperatures
265
+ internal_temperature=float(get_reg(61)),
266
+ radiator_temperature_1=float(get_reg(62)),
267
+ radiator_temperature_2=float(get_reg(63)),
268
+ battery_control_temperature=float(get_reg(65)),
269
+ # Status
270
+ device_status=get_reg(0),
271
+ # BMS data (registers 88-90)
272
+ battery_soh=get_reg(88, 100), # State of Health (%)
273
+ fault_code=get_reg(89), # BMS fault code
274
+ warning_code=get_reg(90), # BMS warning code
275
+ )
276
+
277
+
278
+ @dataclass
279
+ class InverterEnergyData:
280
+ """Energy production and consumption statistics.
281
+
282
+ All values are already scaled to proper units.
283
+ """
284
+
285
+ # Timestamp
286
+ timestamp: datetime = field(default_factory=datetime.now)
287
+
288
+ # Daily energy (kWh)
289
+ pv_energy_today: float = 0.0
290
+ pv1_energy_today: float = 0.0
291
+ pv2_energy_today: float = 0.0
292
+ pv3_energy_today: float = 0.0
293
+ charge_energy_today: float = 0.0
294
+ discharge_energy_today: float = 0.0
295
+ grid_import_today: float = 0.0
296
+ grid_export_today: float = 0.0
297
+ load_energy_today: float = 0.0
298
+ eps_energy_today: float = 0.0
299
+
300
+ # Lifetime energy (kWh)
301
+ pv_energy_total: float = 0.0
302
+ pv1_energy_total: float = 0.0
303
+ pv2_energy_total: float = 0.0
304
+ pv3_energy_total: float = 0.0
305
+ charge_energy_total: float = 0.0
306
+ discharge_energy_total: float = 0.0
307
+ grid_import_total: float = 0.0
308
+ grid_export_total: float = 0.0
309
+ load_energy_total: float = 0.0
310
+ eps_energy_total: float = 0.0
311
+
312
+ # Inverter output energy
313
+ inverter_energy_today: float = 0.0
314
+ inverter_energy_total: float = 0.0
315
+
316
+ @classmethod
317
+ def from_http_response(cls, energy: EnergyInfo) -> InverterEnergyData:
318
+ """Create from HTTP API EnergyInfo response.
319
+
320
+ Args:
321
+ energy: Pydantic model from HTTP API
322
+
323
+ Returns:
324
+ Transport-agnostic energy data with scaling applied
325
+
326
+ Note:
327
+ EnergyInfo uses naming convention like todayYielding, todayCharging, etc.
328
+ Values from API are in 0.1 kWh units, need /10 for kWh.
329
+ """
330
+ from pylxpweb.constants.scaling import scale_energy_value
331
+
332
+ return cls(
333
+ timestamp=datetime.now(),
334
+ # Daily - API returns 0.1 kWh units, scale to kWh
335
+ pv_energy_today=scale_energy_value("todayYielding", energy.todayYielding),
336
+ charge_energy_today=scale_energy_value("todayCharging", energy.todayCharging),
337
+ discharge_energy_today=scale_energy_value("todayDischarging", energy.todayDischarging),
338
+ grid_import_today=scale_energy_value("todayImport", energy.todayImport),
339
+ grid_export_today=scale_energy_value("todayExport", energy.todayExport),
340
+ load_energy_today=scale_energy_value("todayUsage", energy.todayUsage),
341
+ # Lifetime - API returns 0.1 kWh units, scale to kWh
342
+ pv_energy_total=scale_energy_value("totalYielding", energy.totalYielding),
343
+ charge_energy_total=scale_energy_value("totalCharging", energy.totalCharging),
344
+ discharge_energy_total=scale_energy_value("totalDischarging", energy.totalDischarging),
345
+ grid_import_total=scale_energy_value("totalImport", energy.totalImport),
346
+ grid_export_total=scale_energy_value("totalExport", energy.totalExport),
347
+ load_energy_total=scale_energy_value("totalUsage", energy.totalUsage),
348
+ # Note: EnergyInfo doesn't have per-PV-string or inverter/EPS energy
349
+ # fields - those would require different API endpoints
350
+ )
351
+
352
+ @classmethod
353
+ def from_modbus_registers(
354
+ cls,
355
+ input_registers: dict[int, int],
356
+ ) -> InverterEnergyData:
357
+ """Create from Modbus input register values.
358
+
359
+ Args:
360
+ input_registers: Dict mapping register address to raw value
361
+
362
+ Returns:
363
+ Transport-agnostic energy data with scaling applied
364
+ """
365
+ from pylxpweb.constants.scaling import ScaleFactor, apply_scale
366
+
367
+ def get_reg(addr: int, default: int = 0) -> int:
368
+ """Get register value with default."""
369
+ return input_registers.get(addr, default)
370
+
371
+ def get_reg_pair(high_addr: int, low_addr: int) -> int:
372
+ """Get 32-bit value from register pair."""
373
+ high = get_reg(high_addr)
374
+ low = get_reg(low_addr)
375
+ return (high << 16) | low
376
+
377
+ # Modbus energy values are in 0.1 Wh, convert to kWh
378
+ def to_kwh(raw_value: int) -> float:
379
+ """Convert raw register value (0.1 Wh units) to kWh.
380
+
381
+ Conversion: raw / 10 = Wh, then / 1000 = kWh
382
+ Example: 184000 -> 18400 Wh -> 18.4 kWh
383
+ """
384
+ return apply_scale(raw_value, ScaleFactor.SCALE_10) / 1000.0
385
+
386
+ return cls(
387
+ timestamp=datetime.now(),
388
+ # Daily energy from register pairs
389
+ inverter_energy_today=to_kwh(get_reg_pair(45, 46)),
390
+ grid_import_today=to_kwh(get_reg_pair(47, 48)),
391
+ charge_energy_today=to_kwh(get_reg_pair(49, 50)),
392
+ discharge_energy_today=to_kwh(get_reg_pair(51, 52)),
393
+ eps_energy_today=to_kwh(get_reg_pair(53, 54)),
394
+ grid_export_today=to_kwh(get_reg_pair(55, 56)),
395
+ load_energy_today=to_kwh(get_reg_pair(57, 58)),
396
+ pv1_energy_today=to_kwh(get_reg_pair(97, 98)),
397
+ pv2_energy_today=to_kwh(get_reg_pair(99, 100)),
398
+ pv3_energy_today=to_kwh(get_reg_pair(101, 102)),
399
+ # Lifetime energy
400
+ inverter_energy_total=to_kwh(get_reg(36) * 1000),
401
+ grid_import_total=to_kwh(get_reg(37) * 1000),
402
+ charge_energy_total=to_kwh(get_reg(38) * 1000),
403
+ discharge_energy_total=to_kwh(get_reg(39) * 1000),
404
+ eps_energy_total=to_kwh(get_reg(40) * 1000),
405
+ grid_export_total=to_kwh(get_reg(41) * 1000),
406
+ load_energy_total=to_kwh(get_reg(42) * 1000),
407
+ pv1_energy_total=to_kwh(get_reg_pair(91, 92)),
408
+ pv2_energy_total=to_kwh(get_reg_pair(93, 94)),
409
+ pv3_energy_total=to_kwh(get_reg_pair(95, 96)),
410
+ )
411
+
412
+
413
+ @dataclass
414
+ class BatteryData:
415
+ """Individual battery module data.
416
+
417
+ All values are already scaled to proper units.
418
+
419
+ Validation:
420
+ - soc and soh are clamped to 0-100 range
421
+ """
422
+
423
+ # Identity
424
+ battery_index: int = 0 # 0-based index in bank
425
+ serial_number: str = ""
426
+
427
+ # State
428
+ voltage: float = 0.0 # V
429
+ current: float = 0.0 # A
430
+ soc: int = 0 # %
431
+ soh: int = 100 # %
432
+ temperature: float = 0.0 # °C
433
+
434
+ # Capacity
435
+ max_capacity: float = 0.0 # Ah
436
+ current_capacity: float = 0.0 # Ah
437
+ cycle_count: int = 0
438
+
439
+ # Cell data (optional, if available)
440
+ cell_count: int = 0
441
+ cell_voltages: list[float] = field(default_factory=list) # V per cell
442
+ cell_temperatures: list[float] = field(default_factory=list) # °C per cell
443
+ min_cell_voltage: float = 0.0 # V
444
+ max_cell_voltage: float = 0.0 # V
445
+
446
+ # Status
447
+ status: int = 0
448
+ fault_code: int = 0
449
+ warning_code: int = 0
450
+
451
+ def __post_init__(self) -> None:
452
+ """Validate and clamp percentage values."""
453
+ self.soc = _clamp_percentage(self.soc, "battery_soc")
454
+ self.soh = _clamp_percentage(self.soh, "battery_soh")
455
+
456
+
457
+ @dataclass
458
+ class BatteryBankData:
459
+ """Aggregate battery bank data.
460
+
461
+ All values are already scaled to proper units.
462
+
463
+ Validation:
464
+ - soc and soh are clamped to 0-100 range
465
+
466
+ Note:
467
+ battery_count reflects the API-reported count and may differ from
468
+ len(batteries) if the API returns a different count than battery array size.
469
+ """
470
+
471
+ # Timestamp
472
+ timestamp: datetime = field(default_factory=datetime.now)
473
+
474
+ # Aggregate state
475
+ voltage: float = 0.0 # V
476
+ current: float = 0.0 # A
477
+ soc: int = 0 # %
478
+ soh: int = 100 # %
479
+ temperature: float = 0.0 # °C
480
+
481
+ # Power
482
+ charge_power: float = 0.0 # W
483
+ discharge_power: float = 0.0 # W
484
+
485
+ # Capacity
486
+ max_capacity: float = 0.0 # Ah
487
+ current_capacity: float = 0.0 # Ah
488
+
489
+ # Status
490
+ status: int = 0
491
+ fault_code: int = 0
492
+ warning_code: int = 0
493
+
494
+ # Individual batteries
495
+ battery_count: int = 0
496
+ batteries: list[BatteryData] = field(default_factory=list)
497
+
498
+ def __post_init__(self) -> None:
499
+ """Validate and clamp percentage values."""
500
+ self.soc = _clamp_percentage(self.soc, "battery_bank_soc")
501
+ self.soh = _clamp_percentage(self.soh, "battery_bank_soh")