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
@@ -8,6 +8,7 @@ This module provides device control functionality including:
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ import logging
11
12
  from typing import TYPE_CHECKING
12
13
 
13
14
  from pylxpweb.endpoints.base import BaseEndpoint
@@ -20,6 +21,8 @@ from pylxpweb.models import (
20
21
  if TYPE_CHECKING:
21
22
  from pylxpweb.client import LuxpowerClient
22
23
 
24
+ _LOGGER = logging.getLogger(__name__)
25
+
23
26
 
24
27
  class ControlEndpoints(BaseEndpoint):
25
28
  """Device control endpoints for parameters, functions, and quick charge."""
@@ -162,7 +165,72 @@ class ControlEndpoints(BaseEndpoint):
162
165
  response = await self.client._request(
163
166
  "POST", "/WManage/web/maintain/remoteSet/write", data=data
164
167
  )
165
- return SuccessResponse.model_validate(response)
168
+ result = SuccessResponse.model_validate(response)
169
+
170
+ # Invalidate cache after successful write to ensure fresh data on next read
171
+ if result.success:
172
+ self.client.invalidate_cache_for_device(inverter_sn)
173
+
174
+ return result
175
+
176
+ async def write_parameters(
177
+ self,
178
+ inverter_sn: str,
179
+ parameters: dict[int, int],
180
+ client_type: str = "WEB",
181
+ ) -> SuccessResponse:
182
+ """Write multiple configuration parameters to the inverter.
183
+
184
+ WARNING: This changes device configuration!
185
+
186
+ This is a convenience method that writes register values directly.
187
+ For named parameters, use write_parameter() instead.
188
+
189
+ Args:
190
+ inverter_sn: Inverter serial number
191
+ parameters: Dict mapping register addresses to values
192
+ client_type: Client type (WEB/APP)
193
+
194
+ Returns:
195
+ SuccessResponse: Operation result
196
+
197
+ Example:
198
+ # Set multiple registers at once
199
+ await client.control.write_parameters(
200
+ "1234567890",
201
+ {21: 512, 66: 50, 67: 100} # Register addresses and values
202
+ )
203
+ """
204
+ # Note: The API doesn't support batch writes, so we write sequentially
205
+ # For now, just write the first parameter (most common use case is single register)
206
+ # Multi-parameter support would require sequential writes with proper error handling
207
+ if not parameters:
208
+ return SuccessResponse(success=True)
209
+
210
+ # For now, we only support single parameter writes through this method
211
+ # Multi-parameter support would require discovering parameter names from register IDs
212
+ register, value = next(iter(parameters.items()))
213
+
214
+ # This is a simplified implementation - would need register-to-param mapping
215
+ # for production use. For now, used primarily by device classes for single writes.
216
+ await self.client._ensure_authenticated()
217
+
218
+ data = {
219
+ "inverterSn": inverter_sn,
220
+ "data": {str(register): value},
221
+ "clientType": client_type,
222
+ }
223
+
224
+ response = await self.client._request(
225
+ "POST", "/WManage/web/maintain/remoteSet/write", data=data
226
+ )
227
+ result = SuccessResponse.model_validate(response)
228
+
229
+ # Invalidate cache after successful write to ensure fresh data on next read
230
+ if result.success:
231
+ self.client.invalidate_cache_for_device(inverter_sn)
232
+
233
+ return result
166
234
 
167
235
  async def control_function(
168
236
  self,
@@ -219,7 +287,13 @@ class ControlEndpoints(BaseEndpoint):
219
287
  response = await self.client._request(
220
288
  "POST", "/WManage/web/maintain/remoteSet/functionControl", data=data
221
289
  )
222
- return SuccessResponse.model_validate(response)
290
+ result = SuccessResponse.model_validate(response)
291
+
292
+ # Invalidate cache after successful write to ensure fresh data on next read
293
+ if result.success:
294
+ self.client.invalidate_cache_for_device(inverter_sn)
295
+
296
+ return result
223
297
 
224
298
  async def start_quick_charge(
225
299
  self, inverter_sn: str, client_type: str = "WEB"
@@ -304,3 +378,907 @@ class ControlEndpoints(BaseEndpoint):
304
378
  cache_endpoint="quick_charge_status",
305
379
  )
306
380
  return QuickChargeStatus.model_validate(response)
381
+
382
+ async def start_quick_discharge(
383
+ self, inverter_sn: str, client_type: str = "WEB"
384
+ ) -> SuccessResponse:
385
+ """Start quick discharge operation.
386
+
387
+ WARNING: This starts discharging!
388
+
389
+ Args:
390
+ inverter_sn: Inverter serial number
391
+ client_type: Client type (WEB/APP)
392
+
393
+ Returns:
394
+ SuccessResponse: Operation result
395
+
396
+ Example:
397
+ result = await client.control.start_quick_discharge("1234567890")
398
+ if result.success:
399
+ print("Quick discharge started successfully")
400
+ """
401
+ await self.client._ensure_authenticated()
402
+
403
+ data = {"inverterSn": inverter_sn, "clientType": client_type}
404
+
405
+ response = await self.client._request(
406
+ "POST", "/WManage/web/config/quickDischarge/start", data=data
407
+ )
408
+ return SuccessResponse.model_validate(response)
409
+
410
+ async def stop_quick_discharge(
411
+ self, inverter_sn: str, client_type: str = "WEB"
412
+ ) -> SuccessResponse:
413
+ """Stop quick discharge operation.
414
+
415
+ WARNING: This stops discharging!
416
+
417
+ Args:
418
+ inverter_sn: Inverter serial number
419
+ client_type: Client type (WEB/APP)
420
+
421
+ Returns:
422
+ SuccessResponse: Operation result
423
+
424
+ Example:
425
+ result = await client.control.stop_quick_discharge("1234567890")
426
+ if result.success:
427
+ print("Quick discharge stopped successfully")
428
+ """
429
+ await self.client._ensure_authenticated()
430
+
431
+ data = {"inverterSn": inverter_sn, "clientType": client_type}
432
+
433
+ response = await self.client._request(
434
+ "POST", "/WManage/web/config/quickDischarge/stop", data=data
435
+ )
436
+ return SuccessResponse.model_validate(response)
437
+
438
+ # ============================================================================
439
+ # Convenience Helper Methods
440
+ # ============================================================================
441
+
442
+ async def enable_battery_backup(
443
+ self, inverter_sn: str, client_type: str = "WEB"
444
+ ) -> SuccessResponse:
445
+ """Enable battery backup (EPS) mode.
446
+
447
+ Convenience wrapper for control_function(..., "FUNC_EPS_EN", True).
448
+
449
+ Args:
450
+ inverter_sn: Inverter serial number
451
+ client_type: Client type (WEB/APP)
452
+
453
+ Returns:
454
+ SuccessResponse: Operation result
455
+
456
+ Example:
457
+ >>> result = await client.control.enable_battery_backup("1234567890")
458
+ >>> result.success
459
+ True
460
+ """
461
+ return await self.control_function(
462
+ inverter_sn, "FUNC_EPS_EN", True, client_type=client_type
463
+ )
464
+
465
+ async def disable_battery_backup(
466
+ self, inverter_sn: str, client_type: str = "WEB"
467
+ ) -> SuccessResponse:
468
+ """Disable battery backup (EPS) mode.
469
+
470
+ Convenience wrapper for control_function(..., "FUNC_EPS_EN", False).
471
+
472
+ Args:
473
+ inverter_sn: Inverter serial number
474
+ client_type: Client type (WEB/APP)
475
+
476
+ Returns:
477
+ SuccessResponse: Operation result
478
+
479
+ Example:
480
+ >>> result = await client.control.disable_battery_backup("1234567890")
481
+ >>> result.success
482
+ True
483
+ """
484
+ return await self.control_function(
485
+ inverter_sn, "FUNC_EPS_EN", False, client_type=client_type
486
+ )
487
+
488
+ async def enable_battery_backup_ctrl(
489
+ self, inverter_sn: str, client_type: str = "WEB"
490
+ ) -> SuccessResponse:
491
+ """Enable battery backup control mode (working mode).
492
+
493
+ This controls FUNC_BATTERY_BACKUP_CTRL, which is distinct from
494
+ FUNC_EPS_EN (EPS/off-grid mode). Battery backup control is a
495
+ working mode setting that affects how the inverter manages
496
+ battery reserves for backup power.
497
+
498
+ Convenience wrapper for control_function(..., "FUNC_BATTERY_BACKUP_CTRL", True).
499
+
500
+ Args:
501
+ inverter_sn: Inverter serial number
502
+ client_type: Client type (WEB/APP)
503
+
504
+ Returns:
505
+ SuccessResponse: Operation result
506
+
507
+ Example:
508
+ >>> result = await client.control.enable_battery_backup_ctrl("1234567890")
509
+ >>> result.success
510
+ True
511
+ """
512
+ return await self.control_function(
513
+ inverter_sn, "FUNC_BATTERY_BACKUP_CTRL", True, client_type=client_type
514
+ )
515
+
516
+ async def disable_battery_backup_ctrl(
517
+ self, inverter_sn: str, client_type: str = "WEB"
518
+ ) -> SuccessResponse:
519
+ """Disable battery backup control mode (working mode).
520
+
521
+ This controls FUNC_BATTERY_BACKUP_CTRL, which is distinct from
522
+ FUNC_EPS_EN (EPS/off-grid mode). Battery backup control is a
523
+ working mode setting that affects how the inverter manages
524
+ battery reserves for backup power.
525
+
526
+ Convenience wrapper for control_function(..., "FUNC_BATTERY_BACKUP_CTRL", False).
527
+
528
+ Args:
529
+ inverter_sn: Inverter serial number
530
+ client_type: Client type (WEB/APP)
531
+
532
+ Returns:
533
+ SuccessResponse: Operation result
534
+
535
+ Example:
536
+ >>> result = await client.control.disable_battery_backup_ctrl("1234567890")
537
+ >>> result.success
538
+ True
539
+ """
540
+ return await self.control_function(
541
+ inverter_sn, "FUNC_BATTERY_BACKUP_CTRL", False, client_type=client_type
542
+ )
543
+
544
+ async def enable_normal_mode(
545
+ self, inverter_sn: str, client_type: str = "WEB"
546
+ ) -> SuccessResponse:
547
+ """Enable normal operating mode (power on).
548
+
549
+ Convenience wrapper for control_function(..., "FUNC_SET_TO_STANDBY", True).
550
+ Note: FUNC_SET_TO_STANDBY = True means NOT in standby (normal mode).
551
+
552
+ Args:
553
+ inverter_sn: Inverter serial number
554
+ client_type: Client type (WEB/APP)
555
+
556
+ Returns:
557
+ SuccessResponse: Operation result
558
+
559
+ Example:
560
+ >>> result = await client.control.enable_normal_mode("1234567890")
561
+ >>> result.success
562
+ True
563
+ """
564
+ return await self.control_function(
565
+ inverter_sn, "FUNC_SET_TO_STANDBY", True, client_type=client_type
566
+ )
567
+
568
+ async def enable_standby_mode(
569
+ self, inverter_sn: str, client_type: str = "WEB"
570
+ ) -> SuccessResponse:
571
+ """Enable standby mode (power off).
572
+
573
+ Convenience wrapper for control_function(..., "FUNC_SET_TO_STANDBY", False).
574
+ Note: FUNC_SET_TO_STANDBY = False means standby mode is active.
575
+
576
+ WARNING: This powers off the inverter!
577
+
578
+ Args:
579
+ inverter_sn: Inverter serial number
580
+ client_type: Client type (WEB/APP)
581
+
582
+ Returns:
583
+ SuccessResponse: Operation result
584
+
585
+ Example:
586
+ >>> result = await client.control.enable_standby_mode("1234567890")
587
+ >>> result.success
588
+ True
589
+ """
590
+ return await self.control_function(
591
+ inverter_sn, "FUNC_SET_TO_STANDBY", False, client_type=client_type
592
+ )
593
+
594
+ async def enable_grid_peak_shaving(
595
+ self, inverter_sn: str, client_type: str = "WEB"
596
+ ) -> SuccessResponse:
597
+ """Enable grid peak shaving mode.
598
+
599
+ Convenience wrapper for control_function(..., "FUNC_GRID_PEAK_SHAVING", True).
600
+
601
+ Args:
602
+ inverter_sn: Inverter serial number
603
+ client_type: Client type (WEB/APP)
604
+
605
+ Returns:
606
+ SuccessResponse: Operation result
607
+
608
+ Example:
609
+ >>> result = await client.control.enable_grid_peak_shaving("1234567890")
610
+ >>> result.success
611
+ True
612
+ """
613
+ return await self.control_function(
614
+ inverter_sn, "FUNC_GRID_PEAK_SHAVING", True, client_type=client_type
615
+ )
616
+
617
+ async def disable_grid_peak_shaving(
618
+ self, inverter_sn: str, client_type: str = "WEB"
619
+ ) -> SuccessResponse:
620
+ """Disable grid peak shaving mode.
621
+
622
+ Convenience wrapper for control_function(..., "FUNC_GRID_PEAK_SHAVING", False).
623
+
624
+ Args:
625
+ inverter_sn: Inverter serial number
626
+ client_type: Client type (WEB/APP)
627
+
628
+ Returns:
629
+ SuccessResponse: Operation result
630
+
631
+ Example:
632
+ >>> result = await client.control.disable_grid_peak_shaving("1234567890")
633
+ >>> result.success
634
+ True
635
+ """
636
+ return await self.control_function(
637
+ inverter_sn, "FUNC_GRID_PEAK_SHAVING", False, client_type=client_type
638
+ )
639
+
640
+ async def get_battery_backup_status(self, inverter_sn: str) -> bool:
641
+ """Get battery backup (EPS) enabled status.
642
+
643
+ Reads register 21 (function enable) and extracts FUNC_EPS_EN bit.
644
+
645
+ Args:
646
+ inverter_sn: Inverter serial number
647
+
648
+ Returns:
649
+ bool: True if EPS mode is enabled, False otherwise
650
+
651
+ Example:
652
+ >>> enabled = await client.control.get_battery_backup_status("1234567890")
653
+ >>> if enabled:
654
+ >>> print("EPS mode is active")
655
+ """
656
+ response = await self.read_parameters(inverter_sn, 21, 1)
657
+ value = response.parameters.get("FUNC_EPS_EN", False)
658
+ return bool(value)
659
+
660
+ # ============================================================================
661
+ # Working Mode Controls (Issue #16)
662
+ # ============================================================================
663
+
664
+ async def enable_ac_charge_mode(
665
+ self, inverter_sn: str, client_type: str = "WEB"
666
+ ) -> SuccessResponse:
667
+ """Enable AC charge mode to allow battery charging from grid.
668
+
669
+ Convenience wrapper for control_function(..., "FUNC_AC_CHARGE", True).
670
+
671
+ Args:
672
+ inverter_sn: Inverter serial number
673
+ client_type: Client type (WEB/APP)
674
+
675
+ Returns:
676
+ SuccessResponse: Operation result
677
+
678
+ Example:
679
+ >>> result = await client.control.enable_ac_charge_mode("1234567890")
680
+ >>> result.success
681
+ True
682
+ """
683
+ return await self.control_function(
684
+ inverter_sn, "FUNC_AC_CHARGE", True, client_type=client_type
685
+ )
686
+
687
+ async def disable_ac_charge_mode(
688
+ self, inverter_sn: str, client_type: str = "WEB"
689
+ ) -> SuccessResponse:
690
+ """Disable AC charge mode.
691
+
692
+ Convenience wrapper for control_function(..., "FUNC_AC_CHARGE", False).
693
+
694
+ Args:
695
+ inverter_sn: Inverter serial number
696
+ client_type: Client type (WEB/APP)
697
+
698
+ Returns:
699
+ SuccessResponse: Operation result
700
+
701
+ Example:
702
+ >>> result = await client.control.disable_ac_charge_mode("1234567890")
703
+ >>> result.success
704
+ True
705
+ """
706
+ return await self.control_function(
707
+ inverter_sn, "FUNC_AC_CHARGE", False, client_type=client_type
708
+ )
709
+
710
+ async def get_ac_charge_mode_status(self, inverter_sn: str) -> bool:
711
+ """Get current AC charge mode status.
712
+
713
+ Reads register 21 (function enable) and extracts FUNC_AC_CHARGE bit.
714
+
715
+ Args:
716
+ inverter_sn: Inverter serial number
717
+
718
+ Returns:
719
+ bool: True if AC charge mode is enabled, False otherwise
720
+
721
+ Example:
722
+ >>> enabled = await client.control.get_ac_charge_mode_status("1234567890")
723
+ >>> if enabled:
724
+ >>> print("AC charge mode is active")
725
+ """
726
+ response = await self.read_parameters(inverter_sn, 21, 1)
727
+ value = response.parameters.get("FUNC_AC_CHARGE", False)
728
+ return bool(value)
729
+
730
+ async def enable_pv_charge_priority(
731
+ self, inverter_sn: str, client_type: str = "WEB"
732
+ ) -> SuccessResponse:
733
+ """Enable PV charge priority mode during specified hours.
734
+
735
+ Convenience wrapper for control_function(..., "FUNC_FORCED_CHG_EN", True).
736
+
737
+ Args:
738
+ inverter_sn: Inverter serial number
739
+ client_type: Client type (WEB/APP)
740
+
741
+ Returns:
742
+ SuccessResponse: Operation result
743
+
744
+ Example:
745
+ >>> result = await client.control.enable_pv_charge_priority("1234567890")
746
+ >>> result.success
747
+ True
748
+ """
749
+ return await self.control_function(
750
+ inverter_sn, "FUNC_FORCED_CHG_EN", True, client_type=client_type
751
+ )
752
+
753
+ async def disable_pv_charge_priority(
754
+ self, inverter_sn: str, client_type: str = "WEB"
755
+ ) -> SuccessResponse:
756
+ """Disable PV charge priority mode.
757
+
758
+ Convenience wrapper for control_function(..., "FUNC_FORCED_CHG_EN", False).
759
+
760
+ Args:
761
+ inverter_sn: Inverter serial number
762
+ client_type: Client type (WEB/APP)
763
+
764
+ Returns:
765
+ SuccessResponse: Operation result
766
+
767
+ Example:
768
+ >>> result = await client.control.disable_pv_charge_priority("1234567890")
769
+ >>> result.success
770
+ True
771
+ """
772
+ return await self.control_function(
773
+ inverter_sn, "FUNC_FORCED_CHG_EN", False, client_type=client_type
774
+ )
775
+
776
+ async def get_pv_charge_priority_status(self, inverter_sn: str) -> bool:
777
+ """Get current PV charge priority status.
778
+
779
+ Reads register 21 (function enable) and extracts FUNC_FORCED_CHG_EN bit.
780
+
781
+ Args:
782
+ inverter_sn: Inverter serial number
783
+
784
+ Returns:
785
+ bool: True if PV charge priority is enabled, False otherwise
786
+
787
+ Example:
788
+ >>> enabled = await client.control.get_pv_charge_priority_status("1234567890")
789
+ >>> if enabled:
790
+ >>> print("PV charge priority mode is active")
791
+ """
792
+ response = await self.read_parameters(inverter_sn, 21, 1)
793
+ value = response.parameters.get("FUNC_FORCED_CHG_EN", False)
794
+ return bool(value)
795
+
796
+ async def enable_forced_discharge(
797
+ self, inverter_sn: str, client_type: str = "WEB"
798
+ ) -> SuccessResponse:
799
+ """Enable forced discharge mode for grid export.
800
+
801
+ Convenience wrapper for control_function(..., "FUNC_FORCED_DISCHG_EN", True).
802
+
803
+ Args:
804
+ inverter_sn: Inverter serial number
805
+ client_type: Client type (WEB/APP)
806
+
807
+ Returns:
808
+ SuccessResponse: Operation result
809
+
810
+ Example:
811
+ >>> result = await client.control.enable_forced_discharge("1234567890")
812
+ >>> result.success
813
+ True
814
+ """
815
+ return await self.control_function(
816
+ inverter_sn, "FUNC_FORCED_DISCHG_EN", True, client_type=client_type
817
+ )
818
+
819
+ async def disable_forced_discharge(
820
+ self, inverter_sn: str, client_type: str = "WEB"
821
+ ) -> SuccessResponse:
822
+ """Disable forced discharge mode.
823
+
824
+ Convenience wrapper for control_function(..., "FUNC_FORCED_DISCHG_EN", False).
825
+
826
+ Args:
827
+ inverter_sn: Inverter serial number
828
+ client_type: Client type (WEB/APP)
829
+
830
+ Returns:
831
+ SuccessResponse: Operation result
832
+
833
+ Example:
834
+ >>> result = await client.control.disable_forced_discharge("1234567890")
835
+ >>> result.success
836
+ True
837
+ """
838
+ return await self.control_function(
839
+ inverter_sn, "FUNC_FORCED_DISCHG_EN", False, client_type=client_type
840
+ )
841
+
842
+ async def get_forced_discharge_status(self, inverter_sn: str) -> bool:
843
+ """Get current forced discharge status.
844
+
845
+ Reads register 21 (function enable) and extracts FUNC_FORCED_DISCHG_EN bit.
846
+
847
+ Args:
848
+ inverter_sn: Inverter serial number
849
+
850
+ Returns:
851
+ bool: True if forced discharge is enabled, False otherwise
852
+
853
+ Example:
854
+ >>> enabled = await client.control.get_forced_discharge_status("1234567890")
855
+ >>> if enabled:
856
+ >>> print("Forced discharge mode is active")
857
+ """
858
+ response = await self.read_parameters(inverter_sn, 21, 1)
859
+ value = response.parameters.get("FUNC_FORCED_DISCHG_EN", False)
860
+ return bool(value)
861
+
862
+ async def enable_peak_shaving_mode(
863
+ self, inverter_sn: str, client_type: str = "WEB"
864
+ ) -> SuccessResponse:
865
+ """Enable grid peak shaving mode.
866
+
867
+ Convenience wrapper for control_function(..., "FUNC_GRID_PEAK_SHAVING", True).
868
+
869
+ Args:
870
+ inverter_sn: Inverter serial number
871
+ client_type: Client type (WEB/APP)
872
+
873
+ Returns:
874
+ SuccessResponse: Operation result
875
+
876
+ Example:
877
+ >>> result = await client.control.enable_peak_shaving_mode("1234567890")
878
+ >>> result.success
879
+ True
880
+ """
881
+ return await self.control_function(
882
+ inverter_sn, "FUNC_GRID_PEAK_SHAVING", True, client_type=client_type
883
+ )
884
+
885
+ async def disable_peak_shaving_mode(
886
+ self, inverter_sn: str, client_type: str = "WEB"
887
+ ) -> SuccessResponse:
888
+ """Disable grid peak shaving mode.
889
+
890
+ Convenience wrapper for control_function(..., "FUNC_GRID_PEAK_SHAVING", False).
891
+
892
+ Args:
893
+ inverter_sn: Inverter serial number
894
+ client_type: Client type (WEB/APP)
895
+
896
+ Returns:
897
+ SuccessResponse: Operation result
898
+
899
+ Example:
900
+ >>> result = await client.control.disable_peak_shaving_mode("1234567890")
901
+ >>> result.success
902
+ True
903
+ """
904
+ return await self.control_function(
905
+ inverter_sn, "FUNC_GRID_PEAK_SHAVING", False, client_type=client_type
906
+ )
907
+
908
+ async def get_peak_shaving_mode_status(self, inverter_sn: str) -> bool:
909
+ """Get current peak shaving mode status.
910
+
911
+ Reads register 21 (function enable) and extracts FUNC_GRID_PEAK_SHAVING bit.
912
+
913
+ Args:
914
+ inverter_sn: Inverter serial number
915
+
916
+ Returns:
917
+ bool: True if peak shaving mode is enabled, False otherwise
918
+
919
+ Example:
920
+ >>> enabled = await client.control.get_peak_shaving_mode_status("1234567890")
921
+ >>> if enabled:
922
+ >>> print("Peak shaving mode is active")
923
+ """
924
+ response = await self.read_parameters(inverter_sn, 21, 1)
925
+ value = response.parameters.get("FUNC_GRID_PEAK_SHAVING", False)
926
+ return bool(value)
927
+
928
+ # ============================================================================
929
+ # Green Mode Controls (Off-Grid Mode in Web Monitor)
930
+ # ============================================================================
931
+
932
+ async def enable_green_mode(
933
+ self, inverter_sn: str, client_type: str = "WEB"
934
+ ) -> SuccessResponse:
935
+ """Enable green mode (off-grid mode in the web monitoring display).
936
+
937
+ Green Mode controls the off-grid operating mode toggle visible in the
938
+ EG4 web monitoring interface. When enabled, the inverter operates in
939
+ an off-grid optimized configuration.
940
+
941
+ Note: This is FUNC_GREEN_EN in register 110, distinct from FUNC_EPS_EN
942
+ (battery backup/EPS mode) in register 21.
943
+
944
+ Convenience wrapper for control_function(..., "FUNC_GREEN_EN", True).
945
+
946
+ Args:
947
+ inverter_sn: Inverter serial number
948
+ client_type: Client type (WEB/APP)
949
+
950
+ Returns:
951
+ SuccessResponse: Operation result
952
+
953
+ Example:
954
+ >>> result = await client.control.enable_green_mode("1234567890")
955
+ >>> result.success
956
+ True
957
+ """
958
+ return await self.control_function(
959
+ inverter_sn, "FUNC_GREEN_EN", True, client_type=client_type
960
+ )
961
+
962
+ async def disable_green_mode(
963
+ self, inverter_sn: str, client_type: str = "WEB"
964
+ ) -> SuccessResponse:
965
+ """Disable green mode (off-grid mode in the web monitoring display).
966
+
967
+ Green Mode controls the off-grid operating mode toggle visible in the
968
+ EG4 web monitoring interface. When disabled, the inverter operates in
969
+ standard grid-tied configuration.
970
+
971
+ Note: This is FUNC_GREEN_EN in register 110, distinct from FUNC_EPS_EN
972
+ (battery backup/EPS mode) in register 21.
973
+
974
+ Convenience wrapper for control_function(..., "FUNC_GREEN_EN", False).
975
+
976
+ Args:
977
+ inverter_sn: Inverter serial number
978
+ client_type: Client type (WEB/APP)
979
+
980
+ Returns:
981
+ SuccessResponse: Operation result
982
+
983
+ Example:
984
+ >>> result = await client.control.disable_green_mode("1234567890")
985
+ >>> result.success
986
+ True
987
+ """
988
+ return await self.control_function(
989
+ inverter_sn, "FUNC_GREEN_EN", False, client_type=client_type
990
+ )
991
+
992
+ async def get_green_mode_status(self, inverter_sn: str) -> bool:
993
+ """Get current green mode (off-grid mode) status.
994
+
995
+ Green Mode controls the off-grid operating mode toggle visible in the
996
+ EG4 web monitoring interface.
997
+
998
+ Reads register 110 (system functions) and extracts FUNC_GREEN_EN bit.
999
+
1000
+ Args:
1001
+ inverter_sn: Inverter serial number
1002
+
1003
+ Returns:
1004
+ bool: True if green mode is enabled, False otherwise
1005
+
1006
+ Example:
1007
+ >>> enabled = await client.control.get_green_mode_status("1234567890")
1008
+ >>> if enabled:
1009
+ >>> print("Green mode (off-grid) is active")
1010
+ """
1011
+ response = await self.read_parameters(inverter_sn, 110, 1)
1012
+ value = response.parameters.get("FUNC_GREEN_EN", False)
1013
+ return bool(value)
1014
+
1015
+ async def read_device_parameters_ranges(self, inverter_sn: str) -> dict[str, int | bool]:
1016
+ """Read all device parameters across three common register ranges.
1017
+
1018
+ This method combines three read_parameters() calls:
1019
+ - Range 1: 0-126 (System config, grid protection)
1020
+ - Range 2: 127-253 (Additional config)
1021
+ - Range 3: 240-366 (Extended parameters)
1022
+
1023
+ Args:
1024
+ inverter_sn: Inverter serial number
1025
+
1026
+ Returns:
1027
+ dict: Combined parameters from all three ranges
1028
+
1029
+ Example:
1030
+ >>> params = await client.control.read_device_parameters_ranges("1234567890")
1031
+ >>> params["HOLD_AC_CHARGE_POWER_CMD"]
1032
+ 50
1033
+ >>> params["FUNC_EPS_EN"]
1034
+ True
1035
+ """
1036
+ import asyncio
1037
+
1038
+ # Read all three ranges concurrently
1039
+ range1_task = self.read_parameters(inverter_sn, 0, 127)
1040
+ range2_task = self.read_parameters(inverter_sn, 127, 127)
1041
+ range3_task = self.read_parameters(inverter_sn, 240, 127)
1042
+
1043
+ range1, range2, range3 = await asyncio.gather(
1044
+ range1_task, range2_task, range3_task, return_exceptions=True
1045
+ )
1046
+
1047
+ # Combine parameters from all ranges
1048
+ combined: dict[str, int | bool] = {}
1049
+
1050
+ if not isinstance(range1, BaseException):
1051
+ combined.update(range1.parameters)
1052
+
1053
+ if not isinstance(range2, BaseException):
1054
+ combined.update(range2.parameters)
1055
+
1056
+ if not isinstance(range3, BaseException):
1057
+ combined.update(range3.parameters)
1058
+
1059
+ return combined
1060
+
1061
+ # ============================================================================
1062
+ # Battery Current Control (Added in v0.3)
1063
+ # ============================================================================
1064
+
1065
+ async def set_battery_charge_current(
1066
+ self,
1067
+ inverter_sn: str,
1068
+ amperes: int,
1069
+ *,
1070
+ validate_battery_limits: bool = True,
1071
+ ) -> SuccessResponse:
1072
+ """Set battery charge current limit.
1073
+
1074
+ Controls the maximum current allowed to charge batteries.
1075
+
1076
+ Common use cases:
1077
+ - Prevent inverter throttling during high solar production
1078
+ - Time-of-use optimization (reduce charge during peak rates)
1079
+ - Battery health management (gentle charging)
1080
+ - Weather-based automation (reduce on sunny days, maximize on cloudy)
1081
+
1082
+ Power Calculation (48V nominal system):
1083
+ - 50A = ~2.4kW
1084
+ - 100A = ~4.8kW
1085
+ - 150A = ~7.2kW
1086
+ - 200A = ~9.6kW
1087
+ - 250A = ~12kW
1088
+
1089
+ Args:
1090
+ inverter_sn: Inverter serial number
1091
+ amperes: Charge current limit (0-250 A)
1092
+ validate_battery_limits: Warn if value exceeds typical battery limits
1093
+
1094
+ Returns:
1095
+ SuccessResponse: Operation result
1096
+
1097
+ Raises:
1098
+ ValueError: If amperes not in valid range (0-250 A)
1099
+
1100
+ Warning:
1101
+ CRITICAL: Never exceed your battery's maximum charge current rating.
1102
+ Check battery manufacturer specifications before setting high values.
1103
+ Monitor battery temperature during high current operations.
1104
+
1105
+ Example:
1106
+ >>> # Prevent throttling on sunny days (limit to ~4kW charge at 48V)
1107
+ >>> await client.control.set_battery_charge_current("1234567890", 80)
1108
+ SuccessResponse(success=True)
1109
+
1110
+ >>> # Maximum charge on cloudy days
1111
+ >>> await client.control.set_battery_charge_current("1234567890", 200)
1112
+ SuccessResponse(success=True)
1113
+ """
1114
+ if not (0 <= amperes <= 250):
1115
+ raise ValueError(f"Battery charge current must be between 0-250 A, got {amperes}")
1116
+
1117
+ if validate_battery_limits and amperes > 200:
1118
+ _LOGGER.warning(
1119
+ "Setting battery charge current to %d A. "
1120
+ "Ensure this does not exceed your battery's maximum rating. "
1121
+ "Typical limits: 200A for 10kWh, 150A for 7.5kWh, 100A for 5kWh.",
1122
+ amperes,
1123
+ )
1124
+
1125
+ return await self.write_parameter(inverter_sn, "HOLD_LEAD_ACID_CHARGE_RATE", str(amperes))
1126
+
1127
+ async def set_battery_discharge_current(
1128
+ self,
1129
+ inverter_sn: str,
1130
+ amperes: int,
1131
+ *,
1132
+ validate_battery_limits: bool = True,
1133
+ ) -> SuccessResponse:
1134
+ """Set battery discharge current limit.
1135
+
1136
+ Controls the maximum current allowed to discharge from batteries.
1137
+
1138
+ Common use cases:
1139
+ - Preserve battery capacity during grid outages
1140
+ - Extend battery lifespan (conservative discharge)
1141
+ - Emergency power management
1142
+ - Peak load management
1143
+
1144
+ Args:
1145
+ inverter_sn: Inverter serial number
1146
+ amperes: Discharge current limit (0-250 A)
1147
+ validate_battery_limits: Warn if value exceeds typical battery limits
1148
+
1149
+ Returns:
1150
+ SuccessResponse: Operation result
1151
+
1152
+ Raises:
1153
+ ValueError: If amperes not in valid range (0-250 A)
1154
+
1155
+ Warning:
1156
+ Never exceed your battery's maximum discharge current rating.
1157
+ Check battery manufacturer specifications.
1158
+
1159
+ Example:
1160
+ >>> # Conservative discharge for battery longevity
1161
+ >>> await client.control.set_battery_discharge_current("1234567890", 150)
1162
+ SuccessResponse(success=True)
1163
+
1164
+ >>> # Minimal discharge during grid outage
1165
+ >>> await client.control.set_battery_discharge_current("1234567890", 50)
1166
+ SuccessResponse(success=True)
1167
+ """
1168
+ if not (0 <= amperes <= 250):
1169
+ raise ValueError(f"Battery discharge current must be between 0-250 A, got {amperes}")
1170
+
1171
+ if validate_battery_limits and amperes > 200:
1172
+ _LOGGER.warning(
1173
+ "Setting battery discharge current to %d A. "
1174
+ "Ensure this does not exceed your battery's maximum rating.",
1175
+ amperes,
1176
+ )
1177
+
1178
+ return await self.write_parameter(
1179
+ inverter_sn, "HOLD_LEAD_ACID_DISCHARGE_RATE", str(amperes)
1180
+ )
1181
+
1182
+ async def get_battery_charge_current(self, inverter_sn: str) -> int:
1183
+ """Get current battery charge current limit.
1184
+
1185
+ Args:
1186
+ inverter_sn: Inverter serial number
1187
+
1188
+ Returns:
1189
+ int: Current charge current limit in Amperes (0-250 A)
1190
+
1191
+ Example:
1192
+ >>> current = await client.control.get_battery_charge_current("1234567890")
1193
+ >>> print(f"Charge limit: {current} A (~{current * 0.048:.1f} kW at 48V)")
1194
+ Charge limit: 200 A (~9.6 kW at 48V)
1195
+ """
1196
+ params = await self.read_device_parameters_ranges(inverter_sn)
1197
+ return int(params.get("HOLD_LEAD_ACID_CHARGE_RATE", 200))
1198
+
1199
+ async def get_battery_discharge_current(self, inverter_sn: str) -> int:
1200
+ """Get current battery discharge current limit.
1201
+
1202
+ Args:
1203
+ inverter_sn: Inverter serial number
1204
+
1205
+ Returns:
1206
+ int: Current discharge current limit in Amperes (0-250 A)
1207
+
1208
+ Example:
1209
+ >>> current = await client.control.get_battery_discharge_current("1234567890")
1210
+ >>> print(f"Discharge limit: {current} A")
1211
+ Discharge limit: 200 A
1212
+ """
1213
+ params = await self.read_device_parameters_ranges(inverter_sn)
1214
+ return int(params.get("HOLD_LEAD_ACID_DISCHARGE_RATE", 200))
1215
+
1216
+ # ============================================================================
1217
+ # System SOC Limit Controls
1218
+ # ============================================================================
1219
+
1220
+ async def set_system_charge_soc_limit(
1221
+ self,
1222
+ inverter_sn: str,
1223
+ percent: int,
1224
+ ) -> SuccessResponse:
1225
+ """Set the system charge SOC limit.
1226
+
1227
+ Controls the maximum State of Charge (SOC) percentage the battery will
1228
+ charge to during normal operation.
1229
+
1230
+ Args:
1231
+ inverter_sn: Inverter serial number
1232
+ percent: Target SOC limit (0-101%)
1233
+ - 0-100: Stop charging when battery reaches this SOC
1234
+ - 101: Special value to enable top balancing (allows full charge
1235
+ with cell balancing for lithium batteries)
1236
+
1237
+ Returns:
1238
+ SuccessResponse: Operation result
1239
+
1240
+ Raises:
1241
+ ValueError: If percent not in valid range (0-101)
1242
+
1243
+ Note:
1244
+ Setting 101% enables top balancing mode, which allows the battery
1245
+ management system to fully charge and balance individual cells.
1246
+ This is recommended periodically for lithium battery health.
1247
+
1248
+ Example:
1249
+ >>> # Limit charging to 90% for daily use (extends battery life)
1250
+ >>> await client.control.set_system_charge_soc_limit("1234567890", 90)
1251
+ SuccessResponse(success=True)
1252
+
1253
+ >>> # Enable top balancing (charge to 100% with cell balancing)
1254
+ >>> await client.control.set_system_charge_soc_limit("1234567890", 101)
1255
+ SuccessResponse(success=True)
1256
+ """
1257
+ if not (0 <= percent <= 101):
1258
+ raise ValueError(
1259
+ f"System charge SOC limit must be between 0-101%, got {percent}. "
1260
+ "Use 101 for top balancing mode."
1261
+ )
1262
+
1263
+ return await self.write_parameter(inverter_sn, "HOLD_SYSTEM_CHARGE_SOC_LIMIT", str(percent))
1264
+
1265
+ async def get_system_charge_soc_limit(self, inverter_sn: str) -> int:
1266
+ """Get the current system charge SOC limit.
1267
+
1268
+ Args:
1269
+ inverter_sn: Inverter serial number
1270
+
1271
+ Returns:
1272
+ int: Current charge SOC limit (0-101%)
1273
+ - 0-100: Normal SOC limit
1274
+ - 101: Top balancing mode enabled
1275
+
1276
+ Example:
1277
+ >>> limit = await client.control.get_system_charge_soc_limit("1234567890")
1278
+ >>> if limit == 101:
1279
+ >>> print("Top balancing enabled")
1280
+ >>> else:
1281
+ >>> print(f"Charge limit: {limit}%")
1282
+ """
1283
+ params = await self.read_device_parameters_ranges(inverter_sn)
1284
+ return int(params.get("HOLD_SYSTEM_CHARGE_SOC_LIMIT", 100))