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/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