pylxpweb 0.1.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.
- pylxpweb/__init__.py +39 -0
- pylxpweb/client.py +417 -0
- pylxpweb/constants.py +1183 -0
- pylxpweb/endpoints/__init__.py +27 -0
- pylxpweb/endpoints/analytics.py +446 -0
- pylxpweb/endpoints/base.py +43 -0
- pylxpweb/endpoints/control.py +306 -0
- pylxpweb/endpoints/devices.py +250 -0
- pylxpweb/endpoints/export.py +86 -0
- pylxpweb/endpoints/firmware.py +235 -0
- pylxpweb/endpoints/forecasting.py +109 -0
- pylxpweb/endpoints/plants.py +470 -0
- pylxpweb/exceptions.py +23 -0
- pylxpweb/models.py +765 -0
- pylxpweb/py.typed +0 -0
- pylxpweb/registers.py +511 -0
- pylxpweb-0.1.0.dist-info/METADATA +433 -0
- pylxpweb-0.1.0.dist-info/RECORD +19 -0
- pylxpweb-0.1.0.dist-info/WHEEL +4 -0
pylxpweb/models.py
ADDED
|
@@ -0,0 +1,765 @@
|
|
|
1
|
+
"""Data models for Luxpower/EG4 API client.
|
|
2
|
+
|
|
3
|
+
All models use Pydantic for validation and serialization.
|
|
4
|
+
Field names match the API response format for easier parsing.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, Field, field_serializer
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _obfuscate_serial(serial: str) -> str:
|
|
16
|
+
"""Obfuscate serial number, showing only first 2 and last 2 digits."""
|
|
17
|
+
if len(serial) <= 4:
|
|
18
|
+
return "****"
|
|
19
|
+
return f"{serial[:2]}{'*' * (len(serial) - 4)}{serial[-2:]}"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _obfuscate_email(email: str) -> str:
|
|
23
|
+
"""Obfuscate email address."""
|
|
24
|
+
if "@" not in email:
|
|
25
|
+
return "***@***"
|
|
26
|
+
local, domain = email.split("@", 1)
|
|
27
|
+
if len(local) <= 2:
|
|
28
|
+
return f"**@{domain}"
|
|
29
|
+
return f"{local[0]}{'*' * (len(local) - 1)}@{domain}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _obfuscate_coordinate(coord: str | float) -> str:
|
|
33
|
+
"""Obfuscate latitude/longitude to 1 decimal place."""
|
|
34
|
+
try:
|
|
35
|
+
val = float(coord)
|
|
36
|
+
return f"{val:.1f}"
|
|
37
|
+
except (ValueError, TypeError):
|
|
38
|
+
return "***"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class BatteryType(str, Enum):
|
|
42
|
+
"""Battery type enumeration."""
|
|
43
|
+
|
|
44
|
+
LITHIUM = "LITHIUM"
|
|
45
|
+
LEAD_ACID = "LEAD_ACID"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class UserRole(str, Enum):
|
|
49
|
+
"""User role enumeration."""
|
|
50
|
+
|
|
51
|
+
VIEWER = "VIEWER"
|
|
52
|
+
INSTALLER = "INSTALLER"
|
|
53
|
+
ADMIN = "ADMIN"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# Login Response Models
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class RegionInfo(BaseModel):
|
|
60
|
+
"""Region information."""
|
|
61
|
+
|
|
62
|
+
value: str
|
|
63
|
+
text: str
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class CountryInfo(BaseModel):
|
|
67
|
+
"""Country information."""
|
|
68
|
+
|
|
69
|
+
value: str
|
|
70
|
+
text: str
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class UserVisitRecord(BaseModel):
|
|
74
|
+
"""User visit record."""
|
|
75
|
+
|
|
76
|
+
plantId: int
|
|
77
|
+
serialNum: str
|
|
78
|
+
phase: int
|
|
79
|
+
phaseValue: int
|
|
80
|
+
deviceType: int
|
|
81
|
+
deviceTypeValue: int
|
|
82
|
+
subDeviceTypeValue: int
|
|
83
|
+
dtc: int
|
|
84
|
+
dtcValue: int
|
|
85
|
+
powerRating: int
|
|
86
|
+
batteryType: BatteryType
|
|
87
|
+
protocolVersion: int
|
|
88
|
+
|
|
89
|
+
@field_serializer("serialNum")
|
|
90
|
+
def serialize_serial(self, value: str) -> str:
|
|
91
|
+
"""Obfuscate serial number in serialized output."""
|
|
92
|
+
return _obfuscate_serial(value)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class InverterBasic(BaseModel):
|
|
96
|
+
"""Basic inverter information from login response."""
|
|
97
|
+
|
|
98
|
+
serialNum: str
|
|
99
|
+
phase: int
|
|
100
|
+
lost: bool
|
|
101
|
+
dtc: int
|
|
102
|
+
deviceType: int
|
|
103
|
+
subDeviceType: int | None = None
|
|
104
|
+
allowExport2Grid: bool | None = None
|
|
105
|
+
powerRating: int
|
|
106
|
+
deviceTypeText4APP: str
|
|
107
|
+
deviceTypeText: str
|
|
108
|
+
batteryType: BatteryType
|
|
109
|
+
batteryTypeText: str
|
|
110
|
+
standard: str
|
|
111
|
+
slaveVersion: int
|
|
112
|
+
fwVersion: int
|
|
113
|
+
allowGenExercise: bool
|
|
114
|
+
withbatteryData: bool
|
|
115
|
+
hardwareVersion: int
|
|
116
|
+
voltClass: int
|
|
117
|
+
machineType: int
|
|
118
|
+
odm: int
|
|
119
|
+
protocolVersion: int
|
|
120
|
+
parallelMidboxSn: str | None = None
|
|
121
|
+
parallelMidboxDeviceText: str | None = None
|
|
122
|
+
parallelMidboxLost: bool | None = None
|
|
123
|
+
|
|
124
|
+
@field_serializer("serialNum", "parallelMidboxSn")
|
|
125
|
+
def serialize_serial(self, value: str | None) -> str | None:
|
|
126
|
+
"""Obfuscate serial numbers in serialized output."""
|
|
127
|
+
return _obfuscate_serial(value) if value else value
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class ParallelGroupBasic(BaseModel):
|
|
131
|
+
"""Parallel group information."""
|
|
132
|
+
|
|
133
|
+
parallelGroup: str
|
|
134
|
+
parallelFirstDeviceSn: str
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class PlantBasic(BaseModel):
|
|
138
|
+
"""Plant information from login response."""
|
|
139
|
+
|
|
140
|
+
plantId: int
|
|
141
|
+
name: str
|
|
142
|
+
timezoneHourOffset: int
|
|
143
|
+
timezoneMinuteOffset: int
|
|
144
|
+
inverters: list[InverterBasic]
|
|
145
|
+
parallelGroups: list[ParallelGroupBasic]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class TechInfo(BaseModel):
|
|
149
|
+
"""Technical support information."""
|
|
150
|
+
|
|
151
|
+
techInfoType1: str
|
|
152
|
+
techInfo1: str
|
|
153
|
+
techInfoType2: str
|
|
154
|
+
techInfo2: str
|
|
155
|
+
techInfoCount: int
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class LoginResponse(BaseModel):
|
|
159
|
+
"""Login API response."""
|
|
160
|
+
|
|
161
|
+
success: bool
|
|
162
|
+
userId: int
|
|
163
|
+
parentUserId: int | None = None
|
|
164
|
+
username: str
|
|
165
|
+
readonly: bool
|
|
166
|
+
role: UserRole
|
|
167
|
+
realName: str
|
|
168
|
+
email: str
|
|
169
|
+
countryText: str
|
|
170
|
+
currentContinentIndex: int
|
|
171
|
+
currentRegionIndex: int
|
|
172
|
+
regions: list[RegionInfo]
|
|
173
|
+
currentCountryIndex: int
|
|
174
|
+
countrys: list[CountryInfo]
|
|
175
|
+
timezone: str
|
|
176
|
+
timezoneText: str
|
|
177
|
+
language: str
|
|
178
|
+
telNumber: str
|
|
179
|
+
address: str
|
|
180
|
+
platform: str
|
|
181
|
+
userVisitRecord: UserVisitRecord
|
|
182
|
+
plants: list[PlantBasic]
|
|
183
|
+
clusterId: int
|
|
184
|
+
needHideDisChgEnergy: bool
|
|
185
|
+
allowRemoteSupport: bool
|
|
186
|
+
allowViewerVisitOptimalSet: bool
|
|
187
|
+
allowViewerVisitWeatherSet: bool
|
|
188
|
+
chartColorValues: str
|
|
189
|
+
tempUnit: str
|
|
190
|
+
tempUnitText: str
|
|
191
|
+
dateFormat: str
|
|
192
|
+
userChartRecord: str
|
|
193
|
+
firewallNotificationEnable: str
|
|
194
|
+
userCreateDate: str
|
|
195
|
+
userCreatedDays: int
|
|
196
|
+
techInfo: TechInfo | None = None
|
|
197
|
+
|
|
198
|
+
@field_serializer("email")
|
|
199
|
+
def serialize_email(self, value: str) -> str:
|
|
200
|
+
"""Obfuscate email address in serialized output."""
|
|
201
|
+
return _obfuscate_email(value)
|
|
202
|
+
|
|
203
|
+
@field_serializer("telNumber")
|
|
204
|
+
def serialize_phone(self, value: str) -> str:
|
|
205
|
+
"""Obfuscate phone number in serialized output."""
|
|
206
|
+
return "***-***-" + value[-4:] if len(value) >= 4 else "***"
|
|
207
|
+
|
|
208
|
+
@field_serializer("address")
|
|
209
|
+
def serialize_address(self, _value: str) -> str:
|
|
210
|
+
"""Obfuscate address in serialized output."""
|
|
211
|
+
return "***"
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# Plant List Response Models
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class PlantInfo(BaseModel):
|
|
218
|
+
"""Detailed plant information."""
|
|
219
|
+
|
|
220
|
+
id: int
|
|
221
|
+
plantId: int
|
|
222
|
+
name: str
|
|
223
|
+
nominalPower: int
|
|
224
|
+
country: str
|
|
225
|
+
currentTimezoneWithMinute: int
|
|
226
|
+
timezone: str
|
|
227
|
+
daylightSavingTime: bool
|
|
228
|
+
createDate: str
|
|
229
|
+
noticeFault: bool
|
|
230
|
+
noticeWarn: bool
|
|
231
|
+
noticeEmail: str
|
|
232
|
+
noticeEmail2: str
|
|
233
|
+
contactPerson: str
|
|
234
|
+
contactPhone: str
|
|
235
|
+
address: str
|
|
236
|
+
|
|
237
|
+
@field_serializer("noticeEmail", "noticeEmail2")
|
|
238
|
+
def serialize_email(self, value: str) -> str:
|
|
239
|
+
"""Obfuscate email addresses in serialized output."""
|
|
240
|
+
return _obfuscate_email(value) if value else value
|
|
241
|
+
|
|
242
|
+
@field_serializer("contactPhone")
|
|
243
|
+
def serialize_phone(self, value: str) -> str:
|
|
244
|
+
"""Obfuscate phone number in serialized output."""
|
|
245
|
+
return "***-***-" + value[-4:] if len(value) >= 4 else "***"
|
|
246
|
+
|
|
247
|
+
@field_serializer("address")
|
|
248
|
+
def serialize_address(self, _value: str) -> str:
|
|
249
|
+
"""Obfuscate address in serialized output."""
|
|
250
|
+
return "***"
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class PlantListResponse(BaseModel):
|
|
254
|
+
"""Plant list API response."""
|
|
255
|
+
|
|
256
|
+
total: int
|
|
257
|
+
rows: list[PlantInfo]
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# Device Discovery Models
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class InverterDevice(BaseModel):
|
|
264
|
+
"""Detailed inverter device information."""
|
|
265
|
+
|
|
266
|
+
serialNum: str
|
|
267
|
+
phase: int
|
|
268
|
+
lost: bool
|
|
269
|
+
deviceType: int
|
|
270
|
+
subDeviceType: int | None = None
|
|
271
|
+
deviceTypeText4APP: str
|
|
272
|
+
powerRating: int
|
|
273
|
+
batteryType: BatteryType
|
|
274
|
+
allowGenExercise: bool
|
|
275
|
+
withbatteryData: bool
|
|
276
|
+
|
|
277
|
+
@field_serializer("serialNum")
|
|
278
|
+
def serialize_serial(self, value: str) -> str:
|
|
279
|
+
"""Obfuscate serial number in serialized output."""
|
|
280
|
+
return _obfuscate_serial(value)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class ParallelGroupDevice(BaseModel):
|
|
284
|
+
"""Parallel group device information."""
|
|
285
|
+
|
|
286
|
+
parallelGroup: str
|
|
287
|
+
devices: list[InverterDevice]
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
class ParallelGroupDetailsResponse(BaseModel):
|
|
291
|
+
"""Parallel group details API response."""
|
|
292
|
+
|
|
293
|
+
success: bool
|
|
294
|
+
parallelGroups: list[ParallelGroupDevice]
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class InverterListResponse(BaseModel):
|
|
298
|
+
"""Inverter list API response."""
|
|
299
|
+
|
|
300
|
+
success: bool
|
|
301
|
+
rows: list[InverterDevice]
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# Runtime Data Models
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class InverterRuntime(BaseModel):
|
|
308
|
+
"""Inverter runtime data.
|
|
309
|
+
|
|
310
|
+
Note: Many values require scaling:
|
|
311
|
+
- Voltage: divide by 100
|
|
312
|
+
- Current: divide by 100
|
|
313
|
+
- Frequency: divide by 100
|
|
314
|
+
- Power: no scaling (direct watts)
|
|
315
|
+
- Temperature: no scaling (direct Celsius)
|
|
316
|
+
"""
|
|
317
|
+
|
|
318
|
+
success: bool
|
|
319
|
+
serialNum: str
|
|
320
|
+
fwCode: str
|
|
321
|
+
powerRatingText: str
|
|
322
|
+
lost: bool
|
|
323
|
+
hasRuntimeData: bool = True
|
|
324
|
+
statusText: str
|
|
325
|
+
batShared: bool = False
|
|
326
|
+
isParallelEnabled: bool = False
|
|
327
|
+
allowGenExercise: bool = False
|
|
328
|
+
batteryType: BatteryType
|
|
329
|
+
batParallelNum: str | None = None
|
|
330
|
+
batCapacity: str | None = None
|
|
331
|
+
model: int | None = None
|
|
332
|
+
modelText: str | None = None
|
|
333
|
+
serverTime: str
|
|
334
|
+
deviceTime: str
|
|
335
|
+
# PV inputs (voltage requires �100)
|
|
336
|
+
vpv1: int
|
|
337
|
+
vpv2: int
|
|
338
|
+
vpv3: int | None = None
|
|
339
|
+
remainTime: int = 0
|
|
340
|
+
# PV power (watts, no scaling)
|
|
341
|
+
ppv1: int
|
|
342
|
+
ppv2: int
|
|
343
|
+
ppv3: int | None = None
|
|
344
|
+
ppv: int
|
|
345
|
+
# AC voltages (�100 for volts)
|
|
346
|
+
vacr: int
|
|
347
|
+
vacs: int
|
|
348
|
+
vact: int
|
|
349
|
+
# AC frequency (�100 for Hz)
|
|
350
|
+
fac: int
|
|
351
|
+
pf: str
|
|
352
|
+
# EPS voltages and frequency
|
|
353
|
+
vepsr: int
|
|
354
|
+
vepss: int
|
|
355
|
+
vepst: int
|
|
356
|
+
feps: int
|
|
357
|
+
seps: int
|
|
358
|
+
# Grid and user power (watts)
|
|
359
|
+
pToGrid: int
|
|
360
|
+
pToUser: int
|
|
361
|
+
# Temperatures (Celsius, no scaling)
|
|
362
|
+
tinner: int
|
|
363
|
+
tradiator1: int
|
|
364
|
+
tradiator2: int
|
|
365
|
+
tBat: int
|
|
366
|
+
# Bus voltages
|
|
367
|
+
vBus1: int
|
|
368
|
+
vBus2: int
|
|
369
|
+
status: int
|
|
370
|
+
# Battery data
|
|
371
|
+
pCharge: int
|
|
372
|
+
pDisCharge: int
|
|
373
|
+
batPower: int
|
|
374
|
+
batteryColor: str
|
|
375
|
+
soc: int
|
|
376
|
+
vBat: int
|
|
377
|
+
# Inverter/rectifier power
|
|
378
|
+
pinv: int
|
|
379
|
+
prec: int
|
|
380
|
+
peps: int
|
|
381
|
+
# AC couple
|
|
382
|
+
_12KAcCoupleInverterFlow: bool = False
|
|
383
|
+
_12KAcCoupleInverterData: bool = False
|
|
384
|
+
acCouplePower: int = 0
|
|
385
|
+
# Other fields
|
|
386
|
+
hasEpsOverloadRecoveryTime: bool = False
|
|
387
|
+
maxChgCurr: int
|
|
388
|
+
maxDischgCurr: int
|
|
389
|
+
maxChgCurrValue: int | None = None
|
|
390
|
+
maxDischgCurrValue: int | None = None
|
|
391
|
+
bmsCharge: bool
|
|
392
|
+
bmsDischarge: bool
|
|
393
|
+
bmsForceCharge: bool = False
|
|
394
|
+
# Generator
|
|
395
|
+
_12KUsingGenerator: bool = False
|
|
396
|
+
genVolt: int = 0
|
|
397
|
+
genFreq: int = 0
|
|
398
|
+
genPower: int = 0
|
|
399
|
+
genDryContact: str = "OFF"
|
|
400
|
+
# Consumption
|
|
401
|
+
consumptionPower114: int = 0
|
|
402
|
+
consumptionPower: int = 0
|
|
403
|
+
pEpsL1N: int = 0
|
|
404
|
+
pEpsL2N: int = 0
|
|
405
|
+
haspEpsLNValue: bool = False
|
|
406
|
+
# Directions
|
|
407
|
+
directions: dict[str, str] = Field(default_factory=dict)
|
|
408
|
+
# Quick charge/discharge status
|
|
409
|
+
hasUnclosedQuickChargeTask: bool = False
|
|
410
|
+
hasUnclosedQuickDischargeTask: bool = False
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
# Energy Statistics Models
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
class EnergyInfo(BaseModel):
|
|
417
|
+
"""Energy statistics data.
|
|
418
|
+
|
|
419
|
+
All energy values are in Wh (divide by 10 for kWh display).
|
|
420
|
+
Note: serialNum and soc are not present in parallel group energy responses.
|
|
421
|
+
"""
|
|
422
|
+
|
|
423
|
+
success: bool
|
|
424
|
+
serialNum: str | None = None
|
|
425
|
+
soc: int | None = None
|
|
426
|
+
# Today's energy
|
|
427
|
+
todayYielding: int
|
|
428
|
+
todayCharging: int
|
|
429
|
+
todayDischarging: int
|
|
430
|
+
todayImport: int
|
|
431
|
+
todayExport: int
|
|
432
|
+
todayUsage: int
|
|
433
|
+
# Lifetime energy
|
|
434
|
+
totalYielding: int
|
|
435
|
+
totalCharging: int
|
|
436
|
+
totalDischarging: int
|
|
437
|
+
totalImport: int
|
|
438
|
+
totalExport: int
|
|
439
|
+
totalUsage: int
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
# Battery Information Models
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
class BatteryModule(BaseModel):
|
|
446
|
+
"""Individual battery module information.
|
|
447
|
+
|
|
448
|
+
Note: Cell voltages are in millivolts (�1000 for volts).
|
|
449
|
+
"""
|
|
450
|
+
|
|
451
|
+
batteryKey: str
|
|
452
|
+
batterySn: str
|
|
453
|
+
batIndex: int
|
|
454
|
+
lost: bool
|
|
455
|
+
# Voltage (�100 for volts)
|
|
456
|
+
totalVoltage: int
|
|
457
|
+
# Current (�100 for amps)
|
|
458
|
+
current: int
|
|
459
|
+
soc: int
|
|
460
|
+
soh: int
|
|
461
|
+
currentRemainCapacity: int
|
|
462
|
+
currentFullCapacity: int
|
|
463
|
+
# Temperatures (�10 for Celsius)
|
|
464
|
+
batMaxCellTemp: int
|
|
465
|
+
batMinCellTemp: int
|
|
466
|
+
# Cell voltages (millivolts, �1000 for volts)
|
|
467
|
+
batMaxCellVoltage: int
|
|
468
|
+
batMinCellVoltage: int
|
|
469
|
+
cycleCnt: int
|
|
470
|
+
fwVersionText: str
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
class BatteryInfo(BaseModel):
|
|
474
|
+
"""Battery information including individual modules."""
|
|
475
|
+
|
|
476
|
+
success: bool
|
|
477
|
+
serialNum: str
|
|
478
|
+
soc: int
|
|
479
|
+
vBat: int
|
|
480
|
+
pCharge: int
|
|
481
|
+
pDisCharge: int
|
|
482
|
+
batStatus: str
|
|
483
|
+
maxBatteryCharge: int
|
|
484
|
+
currentBatteryCharge: float
|
|
485
|
+
batteryArray: list[BatteryModule]
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
# GridBOSS/MID Device Models
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
class MidboxData(BaseModel):
|
|
492
|
+
"""GridBOSS/MID device runtime data.
|
|
493
|
+
|
|
494
|
+
Note: Voltages, currents, and frequency require scaling (�100).
|
|
495
|
+
"""
|
|
496
|
+
|
|
497
|
+
status: int
|
|
498
|
+
serverTime: str
|
|
499
|
+
deviceTime: str
|
|
500
|
+
# Grid voltages (�100 for volts)
|
|
501
|
+
gridRmsVolt: int
|
|
502
|
+
upsRmsVolt: int
|
|
503
|
+
genRmsVolt: int
|
|
504
|
+
gridL1RmsVolt: int
|
|
505
|
+
gridL2RmsVolt: int
|
|
506
|
+
# Grid currents (�100 for amps)
|
|
507
|
+
gridL1RmsCurr: int
|
|
508
|
+
gridL2RmsCurr: int
|
|
509
|
+
# Power (watts, no scaling)
|
|
510
|
+
gridL1ActivePower: int
|
|
511
|
+
gridL2ActivePower: int
|
|
512
|
+
hybridPower: int
|
|
513
|
+
# Smart port status
|
|
514
|
+
smartPort1Status: int
|
|
515
|
+
smartPort2Status: int
|
|
516
|
+
smartPort3Status: int
|
|
517
|
+
smartPort4Status: int
|
|
518
|
+
# Grid frequency (�100 for Hz)
|
|
519
|
+
gridFreq: int
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
class MidboxRuntime(BaseModel):
|
|
523
|
+
"""GridBOSS/MID device runtime response."""
|
|
524
|
+
|
|
525
|
+
success: bool
|
|
526
|
+
serialNum: str
|
|
527
|
+
fwCode: str
|
|
528
|
+
midboxData: MidboxData
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
# Parameter Control Models
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
class ParameterReadResponse(BaseModel):
|
|
535
|
+
"""Parameter read response.
|
|
536
|
+
|
|
537
|
+
The API returns parameter keys directly in the response dict,
|
|
538
|
+
not nested under a 'parameters' or 'valueFields' key.
|
|
539
|
+
"""
|
|
540
|
+
|
|
541
|
+
success: bool
|
|
542
|
+
inverterSn: str
|
|
543
|
+
deviceType: int
|
|
544
|
+
startRegister: int
|
|
545
|
+
pointNumber: int
|
|
546
|
+
valueFrame: str
|
|
547
|
+
inverterRuntimeDeviceTime: str | None = None
|
|
548
|
+
|
|
549
|
+
# Allow extra fields for all the parameter keys
|
|
550
|
+
model_config = {"extra": "allow"}
|
|
551
|
+
|
|
552
|
+
@property
|
|
553
|
+
def serialNum(self) -> str:
|
|
554
|
+
"""Alias for inverterSn for backwards compatibility."""
|
|
555
|
+
return self.inverterSn
|
|
556
|
+
|
|
557
|
+
@property
|
|
558
|
+
def parameters(self) -> dict[str, Any]:
|
|
559
|
+
"""Extract all parameter fields (excluding metadata fields)."""
|
|
560
|
+
metadata_fields = {
|
|
561
|
+
"success",
|
|
562
|
+
"inverterSn",
|
|
563
|
+
"deviceType",
|
|
564
|
+
"startRegister",
|
|
565
|
+
"pointNumber",
|
|
566
|
+
"valueFrame",
|
|
567
|
+
"inverterRuntimeDeviceTime",
|
|
568
|
+
}
|
|
569
|
+
return {k: v for k, v in self.model_dump().items() if k not in metadata_fields}
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
class QuickChargeStatus(BaseModel):
|
|
573
|
+
"""Quick charge status response."""
|
|
574
|
+
|
|
575
|
+
success: bool
|
|
576
|
+
hasUnclosedQuickChargeTask: bool
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
class SuccessResponse(BaseModel):
|
|
580
|
+
"""Generic success response."""
|
|
581
|
+
|
|
582
|
+
success: bool
|
|
583
|
+
message: str | None = None
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
class ErrorResponse(BaseModel):
|
|
587
|
+
"""Error response."""
|
|
588
|
+
|
|
589
|
+
success: bool
|
|
590
|
+
message: str
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
# Scaling Helper Functions
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def scale_voltage(value: int) -> float:
|
|
597
|
+
"""Scale voltage value (�100)."""
|
|
598
|
+
return value / 100.0
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def scale_current(value: int) -> float:
|
|
602
|
+
"""Scale current value (�100)."""
|
|
603
|
+
return value / 100.0
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def scale_frequency(value: int) -> float:
|
|
607
|
+
"""Scale frequency value (�100)."""
|
|
608
|
+
return value / 100.0
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def scale_cell_voltage(value: int) -> float:
|
|
612
|
+
"""Scale cell voltage value (�1000)."""
|
|
613
|
+
return value / 1000.0
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def scale_temperature(value: int, divisor: int = 10) -> float:
|
|
617
|
+
"""Scale temperature value (�10 or �1 depending on field)."""
|
|
618
|
+
return value / divisor
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def energy_to_kwh(value: int) -> float:
|
|
622
|
+
"""Convert energy from Wh to kWh."""
|
|
623
|
+
return value / 1000.0
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
# Firmware Update Models
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
class UpdateStatus(str, Enum):
|
|
630
|
+
"""Firmware update status enumeration."""
|
|
631
|
+
|
|
632
|
+
READY = "READY"
|
|
633
|
+
UPLOADING = "UPLOADING"
|
|
634
|
+
COMPLETE = "COMPLETE"
|
|
635
|
+
SUCCESS = "SUCCESS"
|
|
636
|
+
FAILED = "FAILED"
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
class UpdateEligibilityMessage(str, Enum):
|
|
640
|
+
"""Update eligibility status messages."""
|
|
641
|
+
|
|
642
|
+
ALLOW_TO_UPDATE = "allowToUpdate"
|
|
643
|
+
DEVICE_UPDATING = "deviceUpdating"
|
|
644
|
+
PARALLEL_GROUP_UPDATING = "parallelGroupUpdating"
|
|
645
|
+
NOT_ALLOWED_IN_PARALLEL = "notAllowedInParallel"
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
class FirmwareUpdateDetails(BaseModel):
|
|
649
|
+
"""Detailed firmware update information."""
|
|
650
|
+
|
|
651
|
+
serialNum: str
|
|
652
|
+
deviceType: int
|
|
653
|
+
standard: str
|
|
654
|
+
firmwareType: str
|
|
655
|
+
fwCodeBeforeUpload: str
|
|
656
|
+
# Current firmware versions
|
|
657
|
+
v1: int # Application firmware version
|
|
658
|
+
v2: int # Parameter firmware version
|
|
659
|
+
v3Value: int
|
|
660
|
+
# Latest available versions (optional - only present when updates are available)
|
|
661
|
+
lastV1: int | None = None
|
|
662
|
+
lastV1FileName: str | None = None
|
|
663
|
+
lastV2: int | None = None
|
|
664
|
+
lastV2FileName: str | None = None
|
|
665
|
+
# Master controller version
|
|
666
|
+
m3Version: int
|
|
667
|
+
# Update compatibility flags
|
|
668
|
+
pcs1UpdateMatch: bool
|
|
669
|
+
pcs2UpdateMatch: bool
|
|
670
|
+
pcs3UpdateMatch: bool
|
|
671
|
+
# Multi-step update flags
|
|
672
|
+
needRunStep2: bool
|
|
673
|
+
needRunStep3: bool
|
|
674
|
+
needRunStep4: bool
|
|
675
|
+
needRunStep5: bool
|
|
676
|
+
# Device type flags
|
|
677
|
+
midbox: bool
|
|
678
|
+
lowVoltBattery: bool
|
|
679
|
+
type6: bool
|
|
680
|
+
|
|
681
|
+
@field_serializer("serialNum")
|
|
682
|
+
def serialize_serial(self, value: str) -> str:
|
|
683
|
+
"""Obfuscate serial number in serialized output."""
|
|
684
|
+
return _obfuscate_serial(value)
|
|
685
|
+
|
|
686
|
+
def has_app_update(self) -> bool:
|
|
687
|
+
"""Check if application firmware update is available."""
|
|
688
|
+
return self.lastV1 is not None and self.v1 < self.lastV1 and self.pcs1UpdateMatch
|
|
689
|
+
|
|
690
|
+
def has_parameter_update(self) -> bool:
|
|
691
|
+
"""Check if parameter firmware update is available."""
|
|
692
|
+
return self.lastV2 is not None and self.v2 < self.lastV2 and self.pcs2UpdateMatch
|
|
693
|
+
|
|
694
|
+
def has_update(self) -> bool:
|
|
695
|
+
"""Check if any firmware update is available."""
|
|
696
|
+
return self.has_app_update() or self.has_parameter_update()
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
class FirmwareUpdateCheck(BaseModel):
|
|
700
|
+
"""Firmware update check response."""
|
|
701
|
+
|
|
702
|
+
success: bool
|
|
703
|
+
details: FirmwareUpdateDetails
|
|
704
|
+
infoForwardUrl: str | None = None
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
class FirmwareDeviceInfo(BaseModel):
|
|
708
|
+
"""Individual device firmware update information."""
|
|
709
|
+
|
|
710
|
+
inverterSn: str
|
|
711
|
+
startTime: str
|
|
712
|
+
stopTime: str
|
|
713
|
+
standardUpdate: bool
|
|
714
|
+
firmware: str
|
|
715
|
+
firmwareType: str
|
|
716
|
+
updateStatus: UpdateStatus
|
|
717
|
+
isSendStartUpdate: bool
|
|
718
|
+
isSendEndUpdate: bool
|
|
719
|
+
packageIndex: int
|
|
720
|
+
updateRate: str
|
|
721
|
+
|
|
722
|
+
@field_serializer("inverterSn")
|
|
723
|
+
def serialize_serial(self, value: str) -> str:
|
|
724
|
+
"""Obfuscate serial number in serialized output."""
|
|
725
|
+
return _obfuscate_serial(value)
|
|
726
|
+
|
|
727
|
+
def is_in_progress(self) -> bool:
|
|
728
|
+
"""Check if update is currently in progress."""
|
|
729
|
+
return (
|
|
730
|
+
self.updateStatus == UpdateStatus.UPLOADING or self.updateStatus == UpdateStatus.READY
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
def is_complete(self) -> bool:
|
|
734
|
+
"""Check if update completed successfully."""
|
|
735
|
+
return (
|
|
736
|
+
self.updateStatus == UpdateStatus.COMPLETE or self.updateStatus == UpdateStatus.SUCCESS
|
|
737
|
+
)
|
|
738
|
+
|
|
739
|
+
def is_failed(self) -> bool:
|
|
740
|
+
"""Check if update failed."""
|
|
741
|
+
return self.updateStatus == UpdateStatus.FAILED
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
class FirmwareUpdateStatus(BaseModel):
|
|
745
|
+
"""Firmware update status response."""
|
|
746
|
+
|
|
747
|
+
receiving: bool
|
|
748
|
+
progressing: bool
|
|
749
|
+
fileReady: bool
|
|
750
|
+
deviceInfos: list[FirmwareDeviceInfo]
|
|
751
|
+
|
|
752
|
+
def has_active_updates(self) -> bool:
|
|
753
|
+
"""Check if any device has an active update."""
|
|
754
|
+
return any(device.is_in_progress() for device in self.deviceInfos)
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
class UpdateEligibilityStatus(BaseModel):
|
|
758
|
+
"""Update eligibility status response."""
|
|
759
|
+
|
|
760
|
+
success: bool
|
|
761
|
+
msg: UpdateEligibilityMessage
|
|
762
|
+
|
|
763
|
+
def is_allowed(self) -> bool:
|
|
764
|
+
"""Check if device is allowed to update."""
|
|
765
|
+
return self.msg == UpdateEligibilityMessage.ALLOW_TO_UPDATE
|