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,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")
|