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
pylxpweb/models.py CHANGED
@@ -12,6 +12,22 @@ from typing import Any
12
12
  from pydantic import BaseModel, Field, field_serializer
13
13
 
14
14
 
15
+ class OperatingMode(str, Enum):
16
+ """Inverter operating modes.
17
+
18
+ These are the two valid operating states for an inverter:
19
+ - NORMAL: Normal operation (power on)
20
+ - STANDBY: Standby mode (power off)
21
+
22
+ Note: Quick Charge and Quick Discharge are not operating modes,
23
+ they are function controls (enable/disable) that work alongside
24
+ the operating mode.
25
+ """
26
+
27
+ NORMAL = "normal"
28
+ STANDBY = "standby"
29
+
30
+
15
31
  def _obfuscate_serial(serial: str) -> str:
16
32
  """Obfuscate serial number, showing only first 2 and last 2 digits."""
17
33
  if len(serial) <= 4:
@@ -43,6 +59,7 @@ class BatteryType(str, Enum):
43
59
 
44
60
  LITHIUM = "LITHIUM"
45
61
  LEAD_ACID = "LEAD_ACID"
62
+ NO_BATTERY = "NO_BATTERY"
46
63
 
47
64
 
48
65
  class UserRole(str, Enum):
@@ -135,23 +152,30 @@ class ParallelGroupBasic(BaseModel):
135
152
 
136
153
 
137
154
  class PlantBasic(BaseModel):
138
- """Plant information from login response."""
155
+ """Plant information from login response.
156
+
157
+ Note: parallelGroups may not be present for all device types (e.g., 12000XP).
158
+ """
139
159
 
140
160
  plantId: int
141
161
  name: str
142
162
  timezoneHourOffset: int
143
163
  timezoneMinuteOffset: int
144
164
  inverters: list[InverterBasic]
145
- parallelGroups: list[ParallelGroupBasic]
165
+ parallelGroups: list[ParallelGroupBasic] = []
146
166
 
147
167
 
148
168
  class TechInfo(BaseModel):
149
- """Technical support information."""
169
+ """Technical support information.
170
+
171
+ Note: techInfoType2/techInfo2 are optional as some regional APIs
172
+ (e.g., EU Luxpower) may only return one tech info item.
173
+ """
150
174
 
151
175
  techInfoType1: str
152
176
  techInfo1: str
153
- techInfoType2: str
154
- techInfo2: str
177
+ techInfoType2: str | None = None
178
+ techInfo2: str | None = None
155
179
  techInfoCount: int
156
180
 
157
181
 
@@ -189,7 +213,7 @@ class LoginResponse(BaseModel):
189
213
  tempUnit: str
190
214
  tempUnitText: str
191
215
  dateFormat: str
192
- userChartRecord: str
216
+ userChartRecord: str | None = None
193
217
  firewallNotificationEnable: str
194
218
  userCreateDate: str
195
219
  userCreatedDays: int
@@ -280,39 +304,124 @@ class InverterDevice(BaseModel):
280
304
  return _obfuscate_serial(value)
281
305
 
282
306
 
283
- class ParallelGroupDevice(BaseModel):
284
- """Parallel group device information."""
307
+ class ParallelGroupDeviceItem(BaseModel):
308
+ """Device in a parallel group from getParallelGroupDetails endpoint."""
285
309
 
286
- parallelGroup: str
287
- devices: list[InverterDevice]
310
+ serialNum: str
311
+ deviceType: int
312
+ subDeviceType: int
313
+ phase: int
314
+ dtc: int
315
+ machineType: int
316
+ parallelIndex: str
317
+ parallelNumText: str
318
+ lost: bool
319
+ roleText: str
320
+ # Optional runtime data fields (present for inverters, not for GridBOSS)
321
+ vpv1: int | None = None
322
+ ppv1: int | None = None
323
+ vpv2: int | None = None
324
+ ppv2: int | None = None
325
+ vpv3: int | None = None
326
+ ppv3: int | None = None
327
+ soc: int | None = None
328
+ vBat: int | None = None
329
+ pCharge: int | None = None
330
+ pDisCharge: int | None = None
331
+ peps: int | None = None
332
+
333
+ @field_serializer("serialNum")
334
+ def serialize_serial(self, value: str) -> str:
335
+ """Obfuscate serial number in serialized output."""
336
+ return _obfuscate_serial(value)
288
337
 
289
338
 
290
339
  class ParallelGroupDetailsResponse(BaseModel):
291
- """Parallel group details API response."""
340
+ """Parallel group details API response.
341
+
342
+ Returns devices in a parallel group including GridBOSS (if present) and inverters.
343
+ """
292
344
 
293
345
  success: bool
294
- parallelGroups: list[ParallelGroupDevice]
346
+ deviceType: int
347
+ parallelMidboxSn: str | None = None
348
+ total: int
349
+ devices: list[ParallelGroupDeviceItem]
295
350
 
296
351
 
297
352
  class InverterListResponse(BaseModel):
298
- """Inverter list API response."""
353
+ """Inverter list API response (deprecated - use login response instead)."""
299
354
 
300
355
  success: bool
301
356
  rows: list[InverterDevice]
302
357
 
303
358
 
359
+ class InverterOverviewItem(BaseModel):
360
+ """Inverter overview/status from inverterOverview/list endpoint."""
361
+
362
+ serialNum: str
363
+ statusText: str
364
+ deviceType: int
365
+ deviceTypeText: str
366
+ phase: int
367
+ plantId: int
368
+ plantName: str
369
+ ppv: int # PV power in watts
370
+ ppvText: str
371
+ pCharge: int # Charge power in watts
372
+ pChargeText: str
373
+ pDisCharge: int # Discharge power in watts
374
+ pDisChargeText: str
375
+ pConsumption: int # Consumption power in watts
376
+ pConsumptionText: str
377
+ soc: str # State of charge (e.g., "58 %")
378
+ vBat: int # Battery voltage (scaled: divide by 10 for actual volts)
379
+ vBatText: str
380
+ totalYielding: int # Total energy generated (raw: divide by 10 for kWh)
381
+ totalYieldingText: str
382
+ totalDischarging: int # Total energy discharged (raw: divide by 10 for kWh)
383
+ totalDischargingText: str
384
+ totalExport: int # Total energy exported (raw: divide by 10 for kWh)
385
+ totalExportText: str
386
+ totalUsage: int # Total energy consumed (raw: divide by 10 for kWh)
387
+ totalUsageText: str
388
+ parallelGroup: str
389
+ parallelIndex: str
390
+ parallelInfo: str
391
+ parallelModel: str
392
+ endUser: str | None = None # Account type: "guest", owner username, or installer name
393
+
394
+ @field_serializer("serialNum")
395
+ def serialize_serial(self, value: str) -> str:
396
+ """Obfuscate serial number in serialized output."""
397
+ return _obfuscate_serial(value)
398
+
399
+
400
+ class InverterOverviewResponse(BaseModel):
401
+ """Response from inverterOverview/list endpoint."""
402
+
403
+ success: bool
404
+ total: int
405
+ rows: list[InverterOverviewItem]
406
+
407
+
304
408
  # Runtime Data Models
305
409
 
306
410
 
307
411
  class InverterRuntime(BaseModel):
308
412
  """Inverter runtime data.
309
413
 
310
- Note: Many values require scaling:
311
- - Voltage: divide by 100
312
- - Current: divide by 100
313
- - Frequency: divide by 100
414
+ Raw values from API. Use property methods for scaled values.
415
+
416
+ Scaling applied by property methods:
417
+ - Most voltages: ÷10 (vpv1-3, vacr/s/t, vepsr/s/t, vBat)
418
+ - Bus voltages: ÷100 (vBus1, vBus2)
419
+ - Frequency: ÷100 (fac, feps, genFreq)
420
+ - Currents: ÷100 (maxChgCurr, maxDischgCurr)
314
421
  - Power: no scaling (direct watts)
315
422
  - Temperature: no scaling (direct Celsius)
423
+
424
+ See: constants.INVERTER_RUNTIME_SCALING for complete mapping
316
425
  """
317
426
 
318
427
  success: bool
@@ -332,16 +441,20 @@ class InverterRuntime(BaseModel):
332
441
  modelText: str | None = None
333
442
  serverTime: str
334
443
  deviceTime: str
444
+ deviceTimeText: str | None = None # Formatted device time (e.g., "2025/12/24")
335
445
  # PV inputs (voltage requires �100)
336
446
  vpv1: int
337
447
  vpv2: int
338
448
  vpv3: int | None = None
449
+ vpv4: int | None = None # Some models have 4 PV inputs
339
450
  remainTime: int = 0
340
451
  # PV power (watts, no scaling)
341
452
  ppv1: int
342
453
  ppv2: int
343
454
  ppv3: int | None = None
455
+ ppv4: int | None = None # Some models have 4 PV inputs
344
456
  ppv: int
457
+ ppvpCharge: int | None = None # PV charge power (alternate field name on some models)
345
458
  # AC voltages (�100 for volts)
346
459
  vacr: int
347
460
  vacs: int
@@ -382,14 +495,17 @@ class InverterRuntime(BaseModel):
382
495
  _12KAcCoupleInverterFlow: bool = False
383
496
  _12KAcCoupleInverterData: bool = False
384
497
  acCouplePower: int = 0
498
+ batteryCapacity: int | None = None # Battery capacity in Ah (int version of batCapacity)
385
499
  # Other fields
386
500
  hasEpsOverloadRecoveryTime: bool = False
387
- maxChgCurr: int
388
- maxDischgCurr: int
501
+ # Note: These fields are optional as some models (e.g., 12000XP) don't return them
502
+ maxChgCurr: int = 0
503
+ maxDischgCurr: int = 0
389
504
  maxChgCurrValue: int | None = None
390
505
  maxDischgCurrValue: int | None = None
391
- bmsCharge: bool
392
- bmsDischarge: bool
506
+ # BMS fields - optional as some models don't support BMS communication
507
+ bmsCharge: bool = False
508
+ bmsDischarge: bool = False
393
509
  bmsForceCharge: bool = False
394
510
  # Generator
395
511
  _12KUsingGenerator: bool = False
@@ -403,8 +519,27 @@ class InverterRuntime(BaseModel):
403
519
  pEpsL1N: int = 0
404
520
  pEpsL2N: int = 0
405
521
  haspEpsLNValue: bool = False
522
+ # Smart Load (12000XP and similar models)
523
+ smartLoadInverterFlow: bool = False
524
+ smartLoadInverterEnable: bool = False
525
+ epsLoadPowerShow: bool = False
526
+ gridLoadPowerShow: bool = False
527
+ pLoadPowerShow: bool = False
528
+ epsLoadPower: int = 0
529
+ gridLoadPower: int = 0
530
+ smartLoadPower: int = 0
406
531
  # Directions
407
532
  directions: dict[str, str] = Field(default_factory=dict)
533
+
534
+ @property
535
+ def pac(self) -> int:
536
+ """AC output power (alias for pToUser for convenience).
537
+
538
+ Returns:
539
+ Power in watts flowing to user loads
540
+ """
541
+ return self.pToUser
542
+
408
543
  # Quick charge/discharge status
409
544
  hasUnclosedQuickChargeTask: bool = False
410
545
  hasUnclosedQuickDischargeTask: bool = False
@@ -416,7 +551,10 @@ class InverterRuntime(BaseModel):
416
551
  class EnergyInfo(BaseModel):
417
552
  """Energy statistics data.
418
553
 
419
- All energy values are in Wh (divide by 10 for kWh display).
554
+ Raw energy values from API are in units of 0.1 kWh:
555
+ - Divide by 10 to get kWh directly
556
+ - Example: 184 → 18.4 kWh
557
+
420
558
  Note: serialNum and soc are not present in parallel group energy responses.
421
559
  """
422
560
 
@@ -445,44 +583,182 @@ class EnergyInfo(BaseModel):
445
583
  class BatteryModule(BaseModel):
446
584
  """Individual battery module information.
447
585
 
448
- Note: Cell voltages are in millivolts (�1000 for volts).
586
+ Raw values from API. Use Battery class properties for scaled values.
587
+
588
+ Scaling (applied by Battery class):
589
+ - totalVoltage: ÷100 (5305 → 53.05V)
590
+ - current: ÷10 (60 → 6.0A) **CRITICAL: Not ÷100**
591
+ - batMaxCellVoltage/batMinCellVoltage: ÷1000 (3317 → 3.317V)
592
+ - batMaxCellTemp/batMinCellTemp: ÷10 (240 → 24.0°C)
593
+
594
+ See: constants.BATTERY_MODULE_SCALING for complete mapping
449
595
  """
450
596
 
597
+ # Identification
451
598
  batteryKey: str
452
599
  batterySn: str
453
600
  batIndex: int
601
+ batteryType: str | None = None
602
+ batteryTypeText: str | None = None
603
+ batBmsModelText: str | None = None
604
+
605
+ # Status
454
606
  lost: bool
455
- # Voltage (�100 for volts)
607
+ lastUpdateTime: str | None = None
608
+
609
+ # Voltage and Current (÷100 for volts, ÷10 for amps)
456
610
  totalVoltage: int
457
- # Current (�100 for amps)
458
611
  current: int
612
+
613
+ # State of Charge/Health
459
614
  soc: int
460
615
  soh: int
616
+
617
+ # Capacity
461
618
  currentRemainCapacity: int
462
619
  currentFullCapacity: int
463
- # Temperatures (�10 for Celsius)
620
+ currentCapacityPercent: int | None = None
621
+ maxBatteryCharge: int | None = None
622
+
623
+ # Temperatures (÷10 for Celsius)
464
624
  batMaxCellTemp: int
465
625
  batMinCellTemp: int
466
- # Cell voltages (millivolts, �1000 for volts)
626
+ batMaxCellNumTemp: int | None = None
627
+ batMinCellNumTemp: int | None = None
628
+
629
+ # Cell Voltages (÷1000 for volts)
467
630
  batMaxCellVoltage: int
468
631
  batMinCellVoltage: int
632
+ batMaxCellNumVolt: int | None = None
633
+ batMinCellNumVolt: int | None = None
634
+
635
+ # Charge Parameters
636
+ batChargeMaxCur: int | None = None
637
+ batChargeVoltRef: int | None = None
638
+
639
+ # Cycle Count and Firmware
469
640
  cycleCnt: int
470
641
  fwVersionText: str
471
642
 
643
+ # Additional Metrics (may be empty strings)
644
+ chgCapacity: str | None = None
645
+ disChgCapacity: str | None = None
646
+ ambientTemp: str | None = None
647
+ mosTemp: str | None = None
648
+ noticeInfo: str | None = None
649
+
472
650
 
473
651
  class BatteryInfo(BaseModel):
474
- """Battery information including individual modules."""
652
+ """Battery information including individual modules.
653
+
654
+ This represents the aggregate battery system data from getBatteryInfo endpoint.
655
+ """
475
656
 
476
657
  success: bool
477
658
  serialNum: str
659
+
660
+ # Status
661
+ lost: bool | None = None
662
+ hasRuntimeData: bool | None = None
663
+ statusText: str | None = None
664
+ batStatus: str
665
+
666
+ # State of Charge
478
667
  soc: int
668
+
669
+ # Voltage (÷10 for volts at aggregate level)
479
670
  vBat: int
671
+ totalVoltageText: str | None = None
672
+
673
+ # Power (direct watts)
674
+ ppv: int | None = None # PV power
480
675
  pCharge: int
481
676
  pDisCharge: int
482
- batStatus: str
677
+ batPower: int | None = None # Battery power
678
+ pinv: int | None = None # Inverter power
679
+ prec: int | None = None # Grid power
680
+ peps: int | None = None # EPS/backup power
681
+
682
+ # Capacity (Ah)
483
683
  maxBatteryCharge: int
484
684
  currentBatteryCharge: float
685
+ remainCapacity: int | None = None
686
+ fullCapacity: int | None = None
687
+ capacityPercent: int | None = None
688
+
689
+ # Current
690
+ currentText: str | None = None
691
+ currentType: str | None = None # "charge" or "discharge"
692
+
693
+ # Individual Battery Modules
485
694
  batteryArray: list[BatteryModule]
695
+ totalNumber: int | None = None # Total battery count
696
+
697
+
698
+ class BatteryListItem(BaseModel):
699
+ """Simplified battery item from getBatteryInfoForSet endpoint."""
700
+
701
+ batteryKey: str
702
+ batterySn: str
703
+ batIndex: int
704
+ lost: bool
705
+
706
+
707
+ class BatteryListResponse(BaseModel):
708
+ """Response from getBatteryInfoForSet endpoint.
709
+
710
+ This endpoint returns a simplified list of batteries without detailed metrics.
711
+ Use get_battery_info() for full battery metrics.
712
+ """
713
+
714
+ success: bool
715
+ serialNum: str
716
+ totalNumber: int
717
+ batteryArray: list[BatteryListItem]
718
+
719
+
720
+ class InverterDetail(BaseModel):
721
+ """Inverter detail information."""
722
+
723
+ deviceText: str
724
+ fwCode: str
725
+ fwCodeText: str
726
+
727
+
728
+ class InverterInfo(BaseModel):
729
+ """Detailed inverter configuration and device information.
730
+
731
+ This endpoint returns static device configuration details,
732
+ not runtime data. Use get_inverter_runtime() for real-time metrics.
733
+ """
734
+
735
+ success: bool
736
+ lost: bool
737
+ datalogSn: str
738
+ serialNum: str
739
+ deviceType: int
740
+ phase: int
741
+ dtc: int
742
+ voltClass: int
743
+ fwVersion: int
744
+ hardwareVersion: int
745
+ subDeviceType: int
746
+ allowExport2Grid: bool
747
+ powerRating: int
748
+ machineType: int
749
+ deviceTypeText: str
750
+ inverterDetail: InverterDetail
751
+ deviceInfo: str
752
+ address: int
753
+ powerRatingText: str
754
+ batteryType: BatteryType
755
+ status: int
756
+ statusText: str
757
+
758
+ @field_serializer("serialNum", "datalogSn")
759
+ def serialize_serial(self, value: str) -> str:
760
+ """Obfuscate serial numbers in serialized output."""
761
+ return _obfuscate_serial(value)
486
762
 
487
763
 
488
764
  # GridBOSS/MID Device Models
@@ -491,33 +767,128 @@ class BatteryInfo(BaseModel):
491
767
  class MidboxData(BaseModel):
492
768
  """GridBOSS/MID device runtime data.
493
769
 
494
- Note: Voltages, currents, and frequency require scaling (100).
770
+ Note: Voltages are in decivolts (÷10), currents in centiamps (÷100),
771
+ frequency in centihertz (÷100). Power values are in watts (no scaling).
772
+ Energy values are in deciwatt-hours (÷10 for kWh).
495
773
  """
496
774
 
497
775
  status: int
498
776
  serverTime: str
499
777
  deviceTime: str
500
- # Grid voltages (�100 for volts)
778
+ # Grid voltages (÷10 for volts, e.g., 2418 = 241.8V)
501
779
  gridRmsVolt: int
502
780
  upsRmsVolt: int
503
781
  genRmsVolt: int
504
782
  gridL1RmsVolt: int
505
783
  gridL2RmsVolt: int
506
- # Grid currents (�100 for amps)
784
+ upsL1RmsVolt: int
785
+ upsL2RmsVolt: int
786
+ genL1RmsVolt: int
787
+ genL2RmsVolt: int
788
+ # Currents (÷100 for amps)
507
789
  gridL1RmsCurr: int
508
790
  gridL2RmsCurr: int
791
+ loadL1RmsCurr: int
792
+ loadL2RmsCurr: int
793
+ genL1RmsCurr: int
794
+ genL2RmsCurr: int
795
+ upsL1RmsCurr: int
796
+ upsL2RmsCurr: int
509
797
  # Power (watts, no scaling)
510
798
  gridL1ActivePower: int
511
799
  gridL2ActivePower: int
800
+ loadL1ActivePower: int
801
+ loadL2ActivePower: int
802
+ genL1ActivePower: int
803
+ genL2ActivePower: int
804
+ upsL1ActivePower: int
805
+ upsL2ActivePower: int
512
806
  hybridPower: int
513
807
  # Smart port status
514
808
  smartPort1Status: int
515
809
  smartPort2Status: int
516
810
  smartPort3Status: int
517
811
  smartPort4Status: int
518
- # Grid frequency (100 for Hz)
812
+ # Grid frequency (÷100 for Hz)
519
813
  gridFreq: int
520
814
 
815
+ # ===========================================
816
+ # Energy Fields (÷10 for kWh)
817
+ # Optional - not all devices report all fields
818
+ # ===========================================
819
+
820
+ # UPS Energy (Today and Lifetime)
821
+ eUpsTodayL1: int | None = None
822
+ eUpsTodayL2: int | None = None
823
+ eUpsTotalL1: int | None = None
824
+ eUpsTotalL2: int | None = None
825
+
826
+ # Grid Export Energy (Today and Lifetime)
827
+ eToGridTodayL1: int | None = None
828
+ eToGridTodayL2: int | None = None
829
+ eToGridTotalL1: int | None = None
830
+ eToGridTotalL2: int | None = None
831
+
832
+ # Grid Import Energy (Today and Lifetime)
833
+ eToUserTodayL1: int | None = None
834
+ eToUserTodayL2: int | None = None
835
+ eToUserTotalL1: int | None = None
836
+ eToUserTotalL2: int | None = None
837
+
838
+ # Load Energy (Today and Lifetime)
839
+ eLoadTodayL1: int | None = None
840
+ eLoadTodayL2: int | None = None
841
+ eLoadTotalL1: int | None = None
842
+ eLoadTotalL2: int | None = None
843
+
844
+ # AC Couple 1 Energy (Today and Lifetime)
845
+ eACcouple1TodayL1: int | None = None
846
+ eACcouple1TodayL2: int | None = None
847
+ eACcouple1TotalL1: int | None = None
848
+ eACcouple1TotalL2: int | None = None
849
+
850
+ # AC Couple 2 Energy (Today and Lifetime)
851
+ eACcouple2TodayL1: int | None = None
852
+ eACcouple2TodayL2: int | None = None
853
+ eACcouple2TotalL1: int | None = None
854
+ eACcouple2TotalL2: int | None = None
855
+
856
+ # AC Couple 3 Energy (Today and Lifetime)
857
+ eACcouple3TodayL1: int | None = None
858
+ eACcouple3TodayL2: int | None = None
859
+ eACcouple3TotalL1: int | None = None
860
+ eACcouple3TotalL2: int | None = None
861
+
862
+ # AC Couple 4 Energy (Today and Lifetime)
863
+ eACcouple4TodayL1: int | None = None
864
+ eACcouple4TodayL2: int | None = None
865
+ eACcouple4TotalL1: int | None = None
866
+ eACcouple4TotalL2: int | None = None
867
+
868
+ # Smart Load 1 Energy (Today and Lifetime)
869
+ eSmartLoad1TodayL1: int | None = None
870
+ eSmartLoad1TodayL2: int | None = None
871
+ eSmartLoad1TotalL1: int | None = None
872
+ eSmartLoad1TotalL2: int | None = None
873
+
874
+ # Smart Load 2 Energy (Today and Lifetime)
875
+ eSmartLoad2TodayL1: int | None = None
876
+ eSmartLoad2TodayL2: int | None = None
877
+ eSmartLoad2TotalL1: int | None = None
878
+ eSmartLoad2TotalL2: int | None = None
879
+
880
+ # Smart Load 3 Energy (Today and Lifetime)
881
+ eSmartLoad3TodayL1: int | None = None
882
+ eSmartLoad3TodayL2: int | None = None
883
+ eSmartLoad3TotalL1: int | None = None
884
+ eSmartLoad3TotalL2: int | None = None
885
+
886
+ # Smart Load 4 Energy (Today and Lifetime)
887
+ eSmartLoad4TodayL1: int | None = None
888
+ eSmartLoad4TodayL2: int | None = None
889
+ eSmartLoad4TotalL1: int | None = None
890
+ eSmartLoad4TotalL2: int | None = None
891
+
521
892
 
522
893
  class MidboxRuntime(BaseModel):
523
894
  """GridBOSS/MID device runtime response."""
@@ -570,10 +941,15 @@ class ParameterReadResponse(BaseModel):
570
941
 
571
942
 
572
943
  class QuickChargeStatus(BaseModel):
573
- """Quick charge status response."""
944
+ """Quick charge/discharge status response.
945
+
946
+ Note: The quickCharge/getStatusInfo endpoint returns status for BOTH
947
+ quick charge and quick discharge operations.
948
+ """
574
949
 
575
950
  success: bool
576
951
  hasUnclosedQuickChargeTask: bool
952
+ hasUnclosedQuickDischargeTask: bool = False # May not be present in older API versions
577
953
 
578
954
 
579
955
  class SuccessResponse(BaseModel):
@@ -683,17 +1059,20 @@ class FirmwareUpdateDetails(BaseModel):
683
1059
  """Obfuscate serial number in serialized output."""
684
1060
  return _obfuscate_serial(value)
685
1061
 
1062
+ @property
686
1063
  def has_app_update(self) -> bool:
687
1064
  """Check if application firmware update is available."""
688
1065
  return self.lastV1 is not None and self.v1 < self.lastV1 and self.pcs1UpdateMatch
689
1066
 
1067
+ @property
690
1068
  def has_parameter_update(self) -> bool:
691
1069
  """Check if parameter firmware update is available."""
692
1070
  return self.lastV2 is not None and self.v2 < self.lastV2 and self.pcs2UpdateMatch
693
1071
 
1072
+ @property
694
1073
  def has_update(self) -> bool:
695
1074
  """Check if any firmware update is available."""
696
- return self.has_app_update() or self.has_parameter_update()
1075
+ return self.has_app_update or self.has_parameter_update
697
1076
 
698
1077
 
699
1078
  class FirmwareUpdateCheck(BaseModel):
@@ -703,6 +1082,48 @@ class FirmwareUpdateCheck(BaseModel):
703
1082
  details: FirmwareUpdateDetails
704
1083
  infoForwardUrl: str | None = None
705
1084
 
1085
+ @classmethod
1086
+ def create_up_to_date(cls, serial_num: str) -> FirmwareUpdateCheck:
1087
+ """Create a FirmwareUpdateCheck indicating firmware is already up to date.
1088
+
1089
+ This is used when the API returns a "firmware is already the latest version"
1090
+ message, which should be treated as a successful check with no update available.
1091
+
1092
+ Args:
1093
+ serial_num: Device serial number
1094
+
1095
+ Returns:
1096
+ FirmwareUpdateCheck with details indicating no update is available
1097
+ """
1098
+ # Create minimal details with no update available
1099
+ # All version fields set to 0, no latest versions, compatibility flags False
1100
+ details = FirmwareUpdateDetails(
1101
+ serialNum=serial_num,
1102
+ deviceType=0,
1103
+ standard="",
1104
+ firmwareType="",
1105
+ fwCodeBeforeUpload="",
1106
+ v1=0, # Current version unknown
1107
+ v2=0,
1108
+ v3Value=0,
1109
+ lastV1=None, # No update available
1110
+ lastV1FileName=None,
1111
+ lastV2=None,
1112
+ lastV2FileName=None,
1113
+ m3Version=0,
1114
+ pcs1UpdateMatch=False, # No update match
1115
+ pcs2UpdateMatch=False,
1116
+ pcs3UpdateMatch=False,
1117
+ needRunStep2=False,
1118
+ needRunStep3=False,
1119
+ needRunStep4=False,
1120
+ needRunStep5=False,
1121
+ midbox=False,
1122
+ lowVoltBattery=False,
1123
+ type6=False,
1124
+ )
1125
+ return cls(success=True, details=details, infoForwardUrl=None)
1126
+
706
1127
 
707
1128
  class FirmwareDeviceInfo(BaseModel):
708
1129
  """Individual device firmware update information."""
@@ -724,20 +1145,55 @@ class FirmwareDeviceInfo(BaseModel):
724
1145
  """Obfuscate serial number in serialized output."""
725
1146
  return _obfuscate_serial(value)
726
1147
 
1148
+ @property
727
1149
  def is_in_progress(self) -> bool:
728
- """Check if update is currently in progress."""
1150
+ """Check if update is currently in progress.
1151
+
1152
+ Uses multiple indicators for reliable detection:
1153
+ - updateStatus must be UPLOADING or READY
1154
+ - isSendEndUpdate must be False (not completed yet)
1155
+ - isSendStartUpdate should be True (update has started)
1156
+
1157
+ This ensures we accurately detect active updates and avoid
1158
+ false positives from completed or failed updates.
1159
+
1160
+ Returns:
1161
+ True if update is actively in progress, False otherwise
1162
+ """
729
1163
  return (
730
- self.updateStatus == UpdateStatus.UPLOADING or self.updateStatus == UpdateStatus.READY
1164
+ (self.updateStatus == UpdateStatus.UPLOADING or self.updateStatus == UpdateStatus.READY)
1165
+ and not self.isSendEndUpdate
1166
+ and self.isSendStartUpdate
731
1167
  )
732
1168
 
1169
+ @property
733
1170
  def is_complete(self) -> bool:
734
- """Check if update completed successfully."""
1171
+ """Check if update completed successfully.
1172
+
1173
+ Uses multiple indicators for reliable detection:
1174
+ - updateStatus is SUCCESS or COMPLETE
1175
+ - isSendEndUpdate is True (end notification sent)
1176
+ - stopTime is populated (not empty string)
1177
+
1178
+ Returns:
1179
+ True if update completed successfully, False otherwise
1180
+ """
735
1181
  return (
736
- self.updateStatus == UpdateStatus.COMPLETE or self.updateStatus == UpdateStatus.SUCCESS
1182
+ (
1183
+ self.updateStatus == UpdateStatus.SUCCESS
1184
+ or self.updateStatus == UpdateStatus.COMPLETE
1185
+ )
1186
+ and self.isSendEndUpdate
1187
+ and bool(self.stopTime.strip())
737
1188
  )
738
1189
 
1190
+ @property
739
1191
  def is_failed(self) -> bool:
740
- """Check if update failed."""
1192
+ """Check if update failed.
1193
+
1194
+ Returns:
1195
+ True if update failed, False otherwise
1196
+ """
741
1197
  return self.updateStatus == UpdateStatus.FAILED
742
1198
 
743
1199
 
@@ -749,9 +1205,10 @@ class FirmwareUpdateStatus(BaseModel):
749
1205
  fileReady: bool
750
1206
  deviceInfos: list[FirmwareDeviceInfo]
751
1207
 
1208
+ @property
752
1209
  def has_active_updates(self) -> bool:
753
1210
  """Check if any device has an active update."""
754
- return any(device.is_in_progress() for device in self.deviceInfos)
1211
+ return any(device.is_in_progress for device in self.deviceInfos)
755
1212
 
756
1213
 
757
1214
  class UpdateEligibilityStatus(BaseModel):
@@ -760,6 +1217,216 @@ class UpdateEligibilityStatus(BaseModel):
760
1217
  success: bool
761
1218
  msg: UpdateEligibilityMessage
762
1219
 
1220
+ @property
763
1221
  def is_allowed(self) -> bool:
764
1222
  """Check if device is allowed to update."""
765
1223
  return self.msg == UpdateEligibilityMessage.ALLOW_TO_UPDATE
1224
+
1225
+
1226
+ class FirmwareUpdateInfo(BaseModel):
1227
+ """Home Assistant-friendly firmware update information.
1228
+
1229
+ This model provides all fields needed to create an Update entity in Home Assistant,
1230
+ including required properties (installed_version, latest_version, title) and
1231
+ optional properties (release_summary, release_url, in_progress, etc.).
1232
+
1233
+ Example:
1234
+ ```python
1235
+ update_info = await inverter.get_firmware_update_info()
1236
+ if update_info.update_available:
1237
+ print(f"Update: {update_info.installed_version} → {update_info.latest_version}")
1238
+ print(f"Release notes: {update_info.release_url}")
1239
+ print(f"Summary: {update_info.release_summary}")
1240
+ ```
1241
+ """
1242
+
1243
+ # Required HA Update Entity properties
1244
+ installed_version: str # Current firmware version (e.g., "IAAB-1300")
1245
+ latest_version: str # Latest available version
1246
+ title: str # Software title (e.g., "Inverter Firmware", "GridBOSS Firmware")
1247
+
1248
+ # Optional HA Update Entity properties
1249
+ release_summary: str | None = None # Brief changelog (max 255 chars)
1250
+ release_url: str | None = None # URL to full release notes
1251
+ in_progress: bool = False # Whether update is currently installing
1252
+ update_percentage: int | None = None # Installation progress (0-100)
1253
+
1254
+ # Additional metadata for HA entity configuration
1255
+ device_class: str = "firmware" # UpdateDeviceClass.FIRMWARE
1256
+ supported_features: list[str] = [] # e.g., ["install", "progress", "release_notes"]
1257
+
1258
+ # Raw API data for advanced use
1259
+ app_version_current: int | None = None # v1 from API
1260
+ app_version_latest: int | None = None # lastV1 from API
1261
+ param_version_current: int | None = None # v2 from API
1262
+ param_version_latest: int | None = None # lastV2 from API
1263
+ app_filename: str | None = None # lastV1FileName from API
1264
+ param_filename: str | None = None # lastV2FileName from API
1265
+
1266
+ @property
1267
+ def update_available(self) -> bool:
1268
+ """Check if firmware update is available.
1269
+
1270
+ Returns:
1271
+ True if latest_version is newer than installed_version.
1272
+ """
1273
+ return self.installed_version != self.latest_version
1274
+
1275
+ @property
1276
+ def has_app_update(self) -> bool:
1277
+ """Check if application firmware update is available.
1278
+
1279
+ Returns:
1280
+ True if app firmware update is available.
1281
+ """
1282
+ return (
1283
+ self.app_version_current is not None
1284
+ and self.app_version_latest is not None
1285
+ and self.app_version_current < self.app_version_latest
1286
+ )
1287
+
1288
+ @property
1289
+ def has_parameter_update(self) -> bool:
1290
+ """Check if parameter firmware update is available.
1291
+
1292
+ Returns:
1293
+ True if parameter firmware update is available.
1294
+ """
1295
+ return (
1296
+ self.param_version_current is not None
1297
+ and self.param_version_latest is not None
1298
+ and self.param_version_current < self.param_version_latest
1299
+ )
1300
+
1301
+ @classmethod
1302
+ def from_api_response(
1303
+ cls,
1304
+ check: FirmwareUpdateCheck,
1305
+ title: str,
1306
+ in_progress: bool = False,
1307
+ update_percentage: int | None = None,
1308
+ ) -> FirmwareUpdateInfo:
1309
+ """Create FirmwareUpdateInfo from API response.
1310
+
1311
+ Args:
1312
+ check: FirmwareUpdateCheck from API
1313
+ title: Device title (e.g., "FlexBOSS21 Firmware", "GridBOSS Firmware")
1314
+ in_progress: Whether update is currently installing
1315
+ update_percentage: Installation progress (0-100)
1316
+
1317
+ Returns:
1318
+ FirmwareUpdateInfo instance with HA-compatible fields.
1319
+
1320
+ Example:
1321
+ ```python
1322
+ api_check = await client.firmware.check_firmware_updates(serial)
1323
+ update_info = FirmwareUpdateInfo.from_api_response(
1324
+ api_check,
1325
+ title="FlexBOSS21 Firmware"
1326
+ )
1327
+ ```
1328
+ """
1329
+ details = check.details
1330
+
1331
+ # Construct latest version from lastV1/lastV2 (or use current if no updates)
1332
+ # Format: {fwCode}-{v1_hex}{v2_hex} (e.g., "IAAB-1600" for v1=22, v2=0)
1333
+ # Note: API returns decimal values, but firmware versions use hexadecimal
1334
+ if details.has_app_update or details.has_parameter_update:
1335
+ # Use lastV1/lastV2 if there's an actual update, otherwise use current
1336
+ latest_v1 = details.lastV1 if details.has_app_update else details.v1
1337
+ latest_v2 = details.lastV2 if details.has_parameter_update else details.v2
1338
+ # Extract firmware code (e.g., "IAAB" from "IAAB-1300")
1339
+ fw_code = (
1340
+ details.fwCodeBeforeUpload.split("-")[0]
1341
+ if "-" in details.fwCodeBeforeUpload
1342
+ else details.fwCodeBeforeUpload[:4]
1343
+ )
1344
+ # Convert to 2-digit hex (uppercase to match API format)
1345
+ latest_version = f"{fw_code}-{latest_v1:02X}{latest_v2:02X}"
1346
+ else:
1347
+ # No updates available
1348
+ latest_version = details.fwCodeBeforeUpload
1349
+
1350
+ # Generate release summary (max 255 chars for HA)
1351
+ # Format as hex to match firmware version format (e.g., IAAB-1300 means v1=0x13)
1352
+ summary_parts = []
1353
+ if details.has_app_update:
1354
+ summary_parts.append(f"App firmware: v{details.v1:02X} → v{details.lastV1:02X}")
1355
+ if details.has_parameter_update:
1356
+ summary_parts.append(f"Parameter firmware: v{details.v2:02X} → v{details.lastV2:02X}")
1357
+ release_summary = "; ".join(summary_parts) if summary_parts else None
1358
+
1359
+ # Determine supported features based on API capabilities
1360
+ supported_features = ["install"] # All devices support install
1361
+ if update_percentage is not None:
1362
+ supported_features.append("progress")
1363
+ if check.infoForwardUrl:
1364
+ supported_features.append("release_notes")
1365
+
1366
+ return cls(
1367
+ # Required HA properties
1368
+ installed_version=details.fwCodeBeforeUpload,
1369
+ latest_version=latest_version,
1370
+ title=title,
1371
+ # Optional HA properties
1372
+ release_summary=release_summary,
1373
+ release_url=check.infoForwardUrl,
1374
+ in_progress=in_progress,
1375
+ update_percentage=update_percentage,
1376
+ # Metadata
1377
+ device_class="firmware",
1378
+ supported_features=supported_features,
1379
+ # Raw API data
1380
+ app_version_current=details.v1,
1381
+ app_version_latest=details.lastV1,
1382
+ param_version_current=details.v2,
1383
+ param_version_latest=details.lastV2,
1384
+ app_filename=details.lastV1FileName,
1385
+ param_filename=details.lastV2FileName,
1386
+ )
1387
+
1388
+
1389
+ # Dongle Connection Status Models
1390
+
1391
+
1392
+ class DongleStatus(BaseModel):
1393
+ """Dongle connection status from findOnlineDatalog endpoint.
1394
+
1395
+ The dongle (datalog) is the communication module that connects
1396
+ inverters to the cloud monitoring service. This model represents
1397
+ its current online/offline status.
1398
+
1399
+ The API returns:
1400
+ - msg: "current" when dongle is actively communicating
1401
+ - msg: "" (empty) when dongle is offline/not communicating
1402
+
1403
+ Example:
1404
+ ```python
1405
+ status = await client.devices.get_dongle_status("BC34000380")
1406
+ if status.is_online:
1407
+ print("Dongle is online and communicating")
1408
+ else:
1409
+ print("Dongle is offline - inverter data may be stale")
1410
+ ```
1411
+ """
1412
+
1413
+ success: bool
1414
+ msg: str = ""
1415
+
1416
+ @property
1417
+ def is_online(self) -> bool:
1418
+ """Check if the dongle is currently online.
1419
+
1420
+ Returns:
1421
+ True if dongle is actively communicating, False otherwise.
1422
+ """
1423
+ return self.msg == "current"
1424
+
1425
+ @property
1426
+ def status_text(self) -> str:
1427
+ """Get human-readable status text.
1428
+
1429
+ Returns:
1430
+ "Online" or "Offline" based on dongle status.
1431
+ """
1432
+ return "Online" if self.is_online else "Offline"