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,378 @@
|
|
|
1
|
+
"""Inverter feature detection and model identification.
|
|
2
|
+
|
|
3
|
+
This module provides dataclasses and utilities for detecting inverter capabilities
|
|
4
|
+
based on device type codes, HOLD_MODEL register values, and runtime parameter probing.
|
|
5
|
+
|
|
6
|
+
Feature detection uses a multi-layer approach:
|
|
7
|
+
1. Model decoding from HOLD_MODEL register (hardware configuration)
|
|
8
|
+
2. Device type code mapping to known model families
|
|
9
|
+
3. Runtime parameter probing for optional features
|
|
10
|
+
4. Clean property-based API for capability checking
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from enum import Enum
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class InverterFamily(str, Enum):
|
|
20
|
+
"""Inverter model family classification.
|
|
21
|
+
|
|
22
|
+
Each family has distinct hardware capabilities and parameter sets.
|
|
23
|
+
The family is determined by the HOLD_DEVICE_TYPE_CODE register value.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
# SNA Series - Split-phase, North America (US market)
|
|
27
|
+
# Device type code: 54
|
|
28
|
+
SNA = "SNA"
|
|
29
|
+
|
|
30
|
+
# PV Series - High-voltage DC, US market (18KPV, etc.)
|
|
31
|
+
# Device type code: 2092
|
|
32
|
+
PV_SERIES = "PV_SERIES"
|
|
33
|
+
|
|
34
|
+
# LXP-EU Series - European market (LXP-EU 12K, etc.)
|
|
35
|
+
# Device type code: 12
|
|
36
|
+
LXP_EU = "LXP_EU"
|
|
37
|
+
|
|
38
|
+
# LXP-LV Series - Low-voltage DC (LXP-LV 6048, etc.)
|
|
39
|
+
# Device type code: varies
|
|
40
|
+
LXP_LV = "LXP_LV"
|
|
41
|
+
|
|
42
|
+
# Unknown model family
|
|
43
|
+
UNKNOWN = "UNKNOWN"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class GridType(str, Enum):
|
|
47
|
+
"""Grid configuration type."""
|
|
48
|
+
|
|
49
|
+
# Split-phase (US residential: 120V/240V)
|
|
50
|
+
SPLIT_PHASE = "split_phase"
|
|
51
|
+
|
|
52
|
+
# Single-phase (EU: 230V)
|
|
53
|
+
SINGLE_PHASE = "single_phase"
|
|
54
|
+
|
|
55
|
+
# Three-phase (commercial/industrial)
|
|
56
|
+
THREE_PHASE = "three_phase"
|
|
57
|
+
|
|
58
|
+
# Unknown grid type
|
|
59
|
+
UNKNOWN = "unknown"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# Device type code to family mapping
|
|
63
|
+
# These values come from HOLD_DEVICE_TYPE_CODE (register 19)
|
|
64
|
+
DEVICE_TYPE_CODE_TO_FAMILY: dict[int, InverterFamily] = {
|
|
65
|
+
# SNA Series (Split-phase, North America)
|
|
66
|
+
54: InverterFamily.SNA,
|
|
67
|
+
# PV Series (High-voltage DC, US) - includes FlexBOSS models
|
|
68
|
+
2092: InverterFamily.PV_SERIES, # 18KPV
|
|
69
|
+
10284: InverterFamily.PV_SERIES, # FlexBOSS21, FlexBOSS18 (21kW/18kW hybrid)
|
|
70
|
+
# LXP-EU Series (European)
|
|
71
|
+
12: InverterFamily.LXP_EU,
|
|
72
|
+
# Add more mappings as devices are discovered
|
|
73
|
+
# Note: GridBOSS (device type code 50) uses API deviceType=9 and is handled
|
|
74
|
+
# separately as a MID (Main Interconnect Device) controller, not as an inverter
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# Known feature sets by model family
|
|
79
|
+
# These represent the default capabilities for each family
|
|
80
|
+
FAMILY_DEFAULT_FEATURES: dict[InverterFamily, dict[str, bool]] = {
|
|
81
|
+
InverterFamily.SNA: {
|
|
82
|
+
"split_phase": True,
|
|
83
|
+
"off_grid_capable": True,
|
|
84
|
+
"discharge_recovery_hysteresis": True, # HOLD_DISCHG_RECOVERY_LAG_SOC/VOLT
|
|
85
|
+
"quick_charge_minute": True, # SNA_HOLD_QUICK_CHARGE_MINUTE
|
|
86
|
+
"three_phase_capable": False,
|
|
87
|
+
"parallel_support": False, # Single inverter typically
|
|
88
|
+
"volt_watt_curve": False,
|
|
89
|
+
"grid_peak_shaving": True,
|
|
90
|
+
"drms_support": False,
|
|
91
|
+
},
|
|
92
|
+
InverterFamily.PV_SERIES: {
|
|
93
|
+
"split_phase": False,
|
|
94
|
+
"off_grid_capable": True,
|
|
95
|
+
"discharge_recovery_hysteresis": False,
|
|
96
|
+
"quick_charge_minute": False,
|
|
97
|
+
"three_phase_capable": True,
|
|
98
|
+
"parallel_support": True,
|
|
99
|
+
"volt_watt_curve": True,
|
|
100
|
+
"grid_peak_shaving": True,
|
|
101
|
+
"drms_support": True,
|
|
102
|
+
},
|
|
103
|
+
InverterFamily.LXP_EU: {
|
|
104
|
+
"split_phase": False,
|
|
105
|
+
"off_grid_capable": True,
|
|
106
|
+
"discharge_recovery_hysteresis": False,
|
|
107
|
+
"quick_charge_minute": False,
|
|
108
|
+
"three_phase_capable": True,
|
|
109
|
+
"parallel_support": True,
|
|
110
|
+
"volt_watt_curve": True,
|
|
111
|
+
"grid_peak_shaving": True,
|
|
112
|
+
"drms_support": True,
|
|
113
|
+
"eu_grid_compliance": True,
|
|
114
|
+
},
|
|
115
|
+
InverterFamily.LXP_LV: {
|
|
116
|
+
"split_phase": False,
|
|
117
|
+
"off_grid_capable": True,
|
|
118
|
+
"discharge_recovery_hysteresis": False,
|
|
119
|
+
"quick_charge_minute": False,
|
|
120
|
+
"three_phase_capable": False,
|
|
121
|
+
"parallel_support": True,
|
|
122
|
+
"volt_watt_curve": False,
|
|
123
|
+
"grid_peak_shaving": True,
|
|
124
|
+
"drms_support": False,
|
|
125
|
+
},
|
|
126
|
+
InverterFamily.UNKNOWN: {
|
|
127
|
+
# Conservative defaults for unknown models
|
|
128
|
+
"split_phase": False,
|
|
129
|
+
"off_grid_capable": True,
|
|
130
|
+
"discharge_recovery_hysteresis": False,
|
|
131
|
+
"quick_charge_minute": False,
|
|
132
|
+
"three_phase_capable": False,
|
|
133
|
+
"parallel_support": False,
|
|
134
|
+
"volt_watt_curve": False,
|
|
135
|
+
"grid_peak_shaving": False,
|
|
136
|
+
"drms_support": False,
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass
|
|
142
|
+
class InverterModelInfo:
|
|
143
|
+
"""Model information from HOLD_MODEL register and API-decoded fields.
|
|
144
|
+
|
|
145
|
+
The HOLD_MODEL register (registers 0-1) contains a bitfield with
|
|
146
|
+
hardware configuration information. The API decodes this bitfield
|
|
147
|
+
and returns individual fields like HOLD_MODEL_lithiumType, etc.
|
|
148
|
+
|
|
149
|
+
This class stores either:
|
|
150
|
+
1. API-decoded fields (preferred, from parameters like HOLD_MODEL_*)
|
|
151
|
+
2. Raw value for reference (bit layout varies by firmware)
|
|
152
|
+
|
|
153
|
+
Example raw values:
|
|
154
|
+
- SNA12K-US: 0x90AC1 (592577)
|
|
155
|
+
- 18KPV: 0x986C0 (624320)
|
|
156
|
+
- LXP-EU 12K: 0x19AC0 (105152)
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
# Raw model value (32-bit from registers 0-1)
|
|
160
|
+
raw_value: int = 0
|
|
161
|
+
|
|
162
|
+
# Decoded fields (from API HOLD_MODEL_* parameters)
|
|
163
|
+
battery_type: int = 0 # 0=Lead-acid, 1=Lithium primary, 2=Hybrid
|
|
164
|
+
lead_acid_type: int = 0 # Lead-acid battery subtype
|
|
165
|
+
lithium_type: int = 0 # Lithium battery protocol (1-6+)
|
|
166
|
+
measurement: int = 0 # Measurement unit type
|
|
167
|
+
meter_brand: int = 0 # CT meter brand
|
|
168
|
+
meter_type: int = 0 # CT meter type
|
|
169
|
+
power_rating: int = 0 # Power rating code (6=12K, 7=15K, 8=18K)
|
|
170
|
+
rule: int = 0 # Grid compliance rule
|
|
171
|
+
rule_mask: int = 0 # Grid compliance mask
|
|
172
|
+
us_version: bool = False # True for US market, False for EU/other
|
|
173
|
+
wireless_meter: bool = False # True if wireless CT meter
|
|
174
|
+
|
|
175
|
+
@classmethod
|
|
176
|
+
def from_raw(cls, raw_value: int) -> InverterModelInfo:
|
|
177
|
+
"""Create InverterModelInfo with just the raw value.
|
|
178
|
+
|
|
179
|
+
Note: The raw value bit layout varies by firmware version, so
|
|
180
|
+
individual fields will remain at defaults. Use from_parameters()
|
|
181
|
+
when API-decoded fields are available.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
raw_value: Raw 32-bit value from HOLD_MODEL register
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
InverterModelInfo with raw_value set
|
|
188
|
+
"""
|
|
189
|
+
return cls(raw_value=raw_value)
|
|
190
|
+
|
|
191
|
+
@classmethod
|
|
192
|
+
def from_parameters(cls, params: dict[str, int | str | bool]) -> InverterModelInfo:
|
|
193
|
+
"""Create InverterModelInfo from API-decoded parameters.
|
|
194
|
+
|
|
195
|
+
The API returns individual HOLD_MODEL_* fields that are already
|
|
196
|
+
decoded from the raw register value.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
params: Dictionary containing HOLD_MODEL_* parameters
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
InverterModelInfo with all fields populated
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
def get_int(key: str, default: int = 0) -> int:
|
|
206
|
+
val = params.get(key, default)
|
|
207
|
+
if isinstance(val, str):
|
|
208
|
+
try:
|
|
209
|
+
return int(val)
|
|
210
|
+
except ValueError:
|
|
211
|
+
return default
|
|
212
|
+
return int(val) if val is not None else default
|
|
213
|
+
|
|
214
|
+
def get_bool(key: str, default: bool = False) -> bool:
|
|
215
|
+
val = params.get(key, default)
|
|
216
|
+
if isinstance(val, str):
|
|
217
|
+
return val.lower() in ("1", "true", "yes")
|
|
218
|
+
return bool(val) if val is not None else default
|
|
219
|
+
|
|
220
|
+
# Parse raw HOLD_MODEL value
|
|
221
|
+
hold_model = params.get("HOLD_MODEL", "0x0")
|
|
222
|
+
if isinstance(hold_model, str) and hold_model.startswith("0x"):
|
|
223
|
+
raw_value = int(hold_model, 16)
|
|
224
|
+
elif isinstance(hold_model, str) and hold_model.isdigit():
|
|
225
|
+
raw_value = int(hold_model)
|
|
226
|
+
else:
|
|
227
|
+
raw_value = int(hold_model) if hold_model else 0
|
|
228
|
+
|
|
229
|
+
return cls(
|
|
230
|
+
raw_value=raw_value,
|
|
231
|
+
battery_type=get_int("HOLD_MODEL_batteryType"),
|
|
232
|
+
lead_acid_type=get_int("HOLD_MODEL_leadAcidType"),
|
|
233
|
+
lithium_type=get_int("HOLD_MODEL_lithiumType"),
|
|
234
|
+
measurement=get_int("HOLD_MODEL_measurement"),
|
|
235
|
+
meter_brand=get_int("HOLD_MODEL_meterBrand"),
|
|
236
|
+
meter_type=get_int("HOLD_MODEL_meterType"),
|
|
237
|
+
power_rating=get_int("HOLD_MODEL_powerRating"),
|
|
238
|
+
rule=get_int("HOLD_MODEL_rule"),
|
|
239
|
+
rule_mask=get_int("HOLD_MODEL_ruleMask"),
|
|
240
|
+
us_version=get_bool("HOLD_MODEL_usVersion"),
|
|
241
|
+
wireless_meter=get_bool("HOLD_MODEL_wirelessMeter"),
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
@property
|
|
245
|
+
def power_rating_kw(self) -> int:
|
|
246
|
+
"""Get power rating in kilowatts.
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
Nominal power rating in kW, or 0 if unknown
|
|
250
|
+
"""
|
|
251
|
+
# Power rating codes observed:
|
|
252
|
+
# 6 = 12kW, 7 = 15kW, 8 = 18kW, 9 = 21kW
|
|
253
|
+
rating_map = {
|
|
254
|
+
4: 6, # 6kW
|
|
255
|
+
5: 8, # 8kW
|
|
256
|
+
6: 12, # 12kW
|
|
257
|
+
7: 15, # 15kW
|
|
258
|
+
8: 18, # 18kW
|
|
259
|
+
9: 21, # 21kW
|
|
260
|
+
}
|
|
261
|
+
return rating_map.get(self.power_rating, 0)
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
def lithium_protocol_name(self) -> str:
|
|
265
|
+
"""Get lithium battery protocol name.
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
Protocol name string
|
|
269
|
+
"""
|
|
270
|
+
# Lithium type codes observed:
|
|
271
|
+
# 1 = Standard lithium, 2 = EG4 protocol, 6 = EU protocol
|
|
272
|
+
protocol_map = {
|
|
273
|
+
0: "None",
|
|
274
|
+
1: "Standard",
|
|
275
|
+
2: "EG4",
|
|
276
|
+
3: "Pylontech",
|
|
277
|
+
4: "Growatt",
|
|
278
|
+
5: "BYD",
|
|
279
|
+
6: "EU Standard",
|
|
280
|
+
}
|
|
281
|
+
return protocol_map.get(self.lithium_type, f"Unknown ({self.lithium_type})")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@dataclass
|
|
285
|
+
class InverterFeatures:
|
|
286
|
+
"""Detected inverter feature capabilities.
|
|
287
|
+
|
|
288
|
+
This class tracks which features are available on a specific inverter,
|
|
289
|
+
determined through a combination of:
|
|
290
|
+
- Device type code (HOLD_DEVICE_TYPE_CODE register)
|
|
291
|
+
- Model family lookup
|
|
292
|
+
- Runtime parameter probing
|
|
293
|
+
|
|
294
|
+
All feature flags default to False (conservative approach).
|
|
295
|
+
"""
|
|
296
|
+
|
|
297
|
+
# Device identification
|
|
298
|
+
device_type_code: int = 0
|
|
299
|
+
model_family: InverterFamily = InverterFamily.UNKNOWN
|
|
300
|
+
model_info: InverterModelInfo = field(default_factory=InverterModelInfo)
|
|
301
|
+
|
|
302
|
+
# Grid configuration
|
|
303
|
+
grid_type: GridType = GridType.UNKNOWN
|
|
304
|
+
|
|
305
|
+
# Hardware capabilities
|
|
306
|
+
split_phase: bool = False # Split-phase grid (US 120V/240V)
|
|
307
|
+
three_phase_capable: bool = False # Three-phase grid support
|
|
308
|
+
off_grid_capable: bool = True # Off-grid/EPS mode support
|
|
309
|
+
parallel_support: bool = False # Multi-inverter parallel operation
|
|
310
|
+
|
|
311
|
+
# Control features
|
|
312
|
+
discharge_recovery_hysteresis: bool = False # SOC/Volt hysteresis on recovery
|
|
313
|
+
quick_charge_minute: bool = False # SNA quick charge minute setting
|
|
314
|
+
volt_watt_curve: bool = False # Volt-Watt curve support
|
|
315
|
+
grid_peak_shaving: bool = False # Grid peak shaving support
|
|
316
|
+
drms_support: bool = False # DRMS (demand response) support
|
|
317
|
+
eu_grid_compliance: bool = False # EU grid compliance features
|
|
318
|
+
|
|
319
|
+
# Runtime-detected capabilities (probed from actual registers)
|
|
320
|
+
has_sna_registers: bool = False # SNA-specific registers present
|
|
321
|
+
has_pv_series_registers: bool = False # PV series registers present
|
|
322
|
+
|
|
323
|
+
@classmethod
|
|
324
|
+
def from_device_type_code(cls, device_type_code: int) -> InverterFeatures:
|
|
325
|
+
"""Create features instance from device type code.
|
|
326
|
+
|
|
327
|
+
Args:
|
|
328
|
+
device_type_code: Value from HOLD_DEVICE_TYPE_CODE register
|
|
329
|
+
|
|
330
|
+
Returns:
|
|
331
|
+
InverterFeatures with family defaults applied
|
|
332
|
+
"""
|
|
333
|
+
family = DEVICE_TYPE_CODE_TO_FAMILY.get(device_type_code, InverterFamily.UNKNOWN)
|
|
334
|
+
unknown_defaults = FAMILY_DEFAULT_FEATURES[InverterFamily.UNKNOWN]
|
|
335
|
+
defaults = FAMILY_DEFAULT_FEATURES.get(family, unknown_defaults)
|
|
336
|
+
|
|
337
|
+
features = cls(
|
|
338
|
+
device_type_code=device_type_code,
|
|
339
|
+
model_family=family,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
# Apply family defaults
|
|
343
|
+
for key, value in defaults.items():
|
|
344
|
+
if hasattr(features, key):
|
|
345
|
+
setattr(features, key, value)
|
|
346
|
+
|
|
347
|
+
# Set grid type based on family
|
|
348
|
+
if family == InverterFamily.SNA:
|
|
349
|
+
features.grid_type = GridType.SPLIT_PHASE
|
|
350
|
+
elif family in (InverterFamily.PV_SERIES, InverterFamily.LXP_EU):
|
|
351
|
+
features.grid_type = GridType.SINGLE_PHASE # Can be three-phase too
|
|
352
|
+
|
|
353
|
+
return features
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def get_inverter_family(device_type_code: int) -> InverterFamily:
|
|
357
|
+
"""Get inverter family from device type code.
|
|
358
|
+
|
|
359
|
+
Args:
|
|
360
|
+
device_type_code: Value from HOLD_DEVICE_TYPE_CODE register
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
InverterFamily enum value
|
|
364
|
+
"""
|
|
365
|
+
return DEVICE_TYPE_CODE_TO_FAMILY.get(device_type_code, InverterFamily.UNKNOWN)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def get_family_features(family: InverterFamily) -> dict[str, bool]:
|
|
369
|
+
"""Get default feature set for a model family.
|
|
370
|
+
|
|
371
|
+
Args:
|
|
372
|
+
family: InverterFamily enum value
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
Dictionary of feature name to enabled status
|
|
376
|
+
"""
|
|
377
|
+
unknown_defaults = FAMILY_DEFAULT_FEATURES[InverterFamily.UNKNOWN]
|
|
378
|
+
return FAMILY_DEFAULT_FEATURES.get(family, unknown_defaults).copy()
|