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