Skip to content

Modules

API Reference

Client

Client

Asynchronous client for talking to a GivEnergy inverter over Modbus TCP.

Holds a long-lived connection drained by a single producer/consumer task pair. All public methods are coroutines and assume they're awaited from the same asyncio event loop.

Concurrency contract

The client is designed to be used from multiple concurrent callers — e.g. a polling loop calling refresh_plant() and entity-write handlers calling one_shot_command() independently. The following invariants hold:

Safe to interleave

  • Reads (refresh_plant, load_config, refresh) and writes (one_shot_command) may run concurrently. Their request/response pairs occupy disjoint shape-hash spaces, so they never collide in the in-flight tracking dict.
  • tx_queue is a FIFO drained by a single producer task with rate limiting between frames; bytes from one frame never interleave with another. A queued frame whose response future is already done (i.e. resolved by a late arrival from a previous attempt) is skipped at dequeue time rather than written to the wire, so retry storms don't duplicate work the inverter has already done.
  • Incoming frames are reassembled and dispatched serially by the consumer task, so register-cache mutations are applied one PDU at a time.

Must be serialised

  • detect() mutates plant.capabilities (including in-place appends to its address lists) and must not run concurrently with anything that reads those fields — most importantly refresh() and load_config(). In typical use detect() runs once at connect time before the polling loop starts, which satisfies this naturally. Downstream consumers caching capabilities across restarts can bypass detect() on reconnect entirely.

Practical guidance for downstream consumers

  • Take a per-client lock around refresh_plant() so successive polls don't overlap. Writes don't need the same lock — they're free to land between polls.
  • Connection loss is surfaced three ways: self.connected flips to False, the noticing task logs a WARNING, and every in-flight or subsequently attempted request raises ConnectionLost (a CommunicationError that is also a TimeoutError, so legacy except TimeoutError handling keeps working — catch ConnectionLost first to distinguish reconnect-me from a genuine stall). connect() is idempotent and tears down the previous connection on its own, so it can be called directly as a reconnect primitive.
Source code in givenergy_modbus/client/client.py
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
class Client:
    """Asynchronous client for talking to a GivEnergy inverter over Modbus TCP.

    Holds a long-lived connection drained by a single producer/consumer task pair.
    All public methods are coroutines and assume they're awaited from the same
    asyncio event loop.

    Concurrency contract
    --------------------

    The client is designed to be used from multiple concurrent callers — e.g. a
    polling loop calling ``refresh_plant()`` and entity-write handlers calling
    ``one_shot_command()`` independently. The following invariants hold:

    **Safe to interleave**

    - Reads (``refresh_plant``, ``load_config``, ``refresh``) and writes
      (``one_shot_command``) may run concurrently. Their request/response pairs
      occupy disjoint shape-hash spaces, so they never collide in the in-flight
      tracking dict.
    - ``tx_queue`` is a FIFO drained by a single producer task with rate limiting
      between frames; bytes from one frame never interleave with another. A queued
      frame whose response future is already done (i.e. resolved by a late arrival
      from a previous attempt) is skipped at dequeue time rather than written to
      the wire, so retry storms don't duplicate work the inverter has already done.
    - Incoming frames are reassembled and dispatched serially by the consumer
      task, so register-cache mutations are applied one PDU at a time.

    **Must be serialised**

    - ``detect()`` mutates ``plant.capabilities`` (including in-place appends to
      its address lists) and must not run concurrently with anything that reads
      those fields — most importantly ``refresh()`` and ``load_config()``.
      In typical use ``detect()`` runs once at connect time before the polling
      loop starts, which satisfies this naturally. Downstream consumers caching
      capabilities across restarts can bypass ``detect()`` on reconnect entirely.

    **Practical guidance for downstream consumers**

    - Take a per-client lock around ``refresh_plant()`` so successive polls don't
      overlap. Writes don't need the same lock — they're free to land between
      polls.
    - Connection loss is surfaced three ways: ``self.connected`` flips to
      ``False``, the noticing task logs a WARNING, and every in-flight or
      subsequently attempted request raises ``ConnectionLost`` (a
      ``CommunicationError`` that is also a ``TimeoutError``, so legacy
      ``except TimeoutError`` handling keeps working — catch ``ConnectionLost``
      first to distinguish reconnect-me from a genuine stall). ``connect()`` is
      idempotent and tears down the previous connection on its own, so it can
      be called directly as a reconnect primitive.
    """

    framer: Framer
    expected_responses: dict[int, Future[TransparentResponse]] = {}
    plant: Plant
    # refresh_count: int = 0
    # debug_frames: Dict[str, Queue]
    connected = False
    _shutting_down = False
    _capture_sink: Callable[[Direction, bytes], None] | None = None
    # Per-direction stream redactors for an active capture — carry a small tail
    # across socket-read chunks so a serial split across a boundary is still
    # redacted (#117). Created in capture_frames(), None when no capture runs.
    _capture_redactor_rx: "FrameRedactor | None" = None
    _capture_redactor_tx: "FrameRedactor | None" = None
    reader: StreamReader
    writer: StreamWriter
    network_consumer_task: Task | None
    network_producer_task: Task | None

    # (raw_frame, frame_sent_future, response_future). frame_sent_future is signalled by
    # the producer once the frame has been written; response_future, when present, is
    # consulted before writing so a frame whose response already arrived (e.g. as a late
    # arrival to a previous attempt) is skipped rather than duplicated on the wire.
    tx_queue: Queue[tuple[bytes, Future | None, Future | None]]

    def __init__(
        self,
        host: str,
        port: int,
        connect_timeout: float = 2.0,
        tx_message_wait: float = 0.25,
        tx_jitter: float = 0.1,
        plant: Plant | None = None,
        splice_heal_seconds: float | None = None,
        splice_reject_heal_seconds: float | None = None,
    ) -> None:
        self.host = host
        self.port = port
        self.connect_timeout = connect_timeout
        # Minimum gap between consecutive frames hitting the wire. Empirically
        # load-bearing across hardware generations — see issue #71 for context.
        self.tx_message_wait = tx_message_wait
        # Upper bound on the additive random jitter applied on top of
        # tx_message_wait. Disperses concurrent bursts (polling ticks, retry
        # storms) so they don't clump at fixed 250 ms boundaries. Asymmetric
        # by design — preserves the historic tx_message_wait floor and only
        # ever lengthens the gap; set to 0 to disable.
        self.tx_jitter = tx_jitter
        self.framer = ClientFramer()
        # ``plant`` is for single-owner pre-built plants only (e.g. restoring a
        # persisted PlantCapabilities). Do NOT share one Plant across two active
        # Clients: both call plant.update() into the same register_caches, and
        # two devices that answer at the same Modbus address (e.g. EMS + direct
        # inverter both at 0x11) will overwrite each other's cache. The safe
        # multi-Client path is separate Plants + plant.add_direct_source().
        self.plant = plant if plant is not None else Plant()
        # How long to hold last-good for a disputed *constant* battery register (num_cells,
        # bms_firmware_version) before healing to a sustained new value (#286). Applied only when
        # explicitly given, so an injected plant's own splice_heal_seconds isn't silently clobbered;
        # otherwise the Plant field's own default (900 s) stands. Larger = more robust against
        # ongoing splice corruption (which reverts in minutes); smaller = faster recovery from a
        # genuinely poisoned cold-start baseline. Consumers can watch plant.splice_held_count +
        # plant.block_age() to see when data is being held.
        if splice_heal_seconds is not None:
            self.plant.splice_heal_seconds = splice_heal_seconds
        # Opt-in recovery for a sustained *legitimate* >=2-physics battery step — the near-full-SOC
        # charge knee, which otherwise hard-rejects and freezes telemetry until it settles (#299).
        # None (default) leaves it disabled (the >=2-physics reject stays terminal); a float (e.g.
        # 300) enables the heal with that time bound. Off by default because the positive path can't
        # be validated against the existing corpus — opt in on a pack that tops out regularly.
        # Applied only when explicitly given, so an injected plant's own value isn't clobbered.
        if splice_reject_heal_seconds is not None:
            self.plant.splice_reject_heal_seconds = splice_reject_heal_seconds
        self.tx_queue = Queue(maxsize=20)
        self.expected_responses = {}
        self._shutting_down = False
        self._connection_lost = False
        # Reader-EOF reconnect-churn coalescing state (#355-style, see _note_eof_drop). Persists
        # across reconnects — a marginal dongle drops repeatedly, so the burst must be tracked on
        # the long-lived Client, not per-connection.
        self._eof_drop_count = 0
        self._eof_last_warn_at: datetime | None = None
        self._eof_last_drop_at: datetime | None = None
        self.network_producer_task: Task | None = None
        self.network_consumer_task: Task | None = None
        # self.debug_frames = {
        #     'all': Queue(maxsize=1000),
        #     'error': Queue(maxsize=1000),
        # }

    async def connect(self) -> None:
        """Connect to the remote host and start background tasks.

        Idempotent: if the client is already connected, the existing connection
        and background tasks are torn down before establishing a new one. This
        makes ``connect()`` safe to use as a reconnect primitive without a
        separate ``close()`` step, and guarantees the new background tasks see
        ``_shutting_down`` as False even after a prior ``close()``.
        """
        # After an unexpected EOF the consumer sets ``connected = False`` and exits,
        # but the reader/writer/producer-task can still be live — calling
        # ``connect()`` again without a tear-down would leave the old producer task
        # running against shared state alongside the new one. Treat any of those
        # leftover resources as "needs cleanup", not just the ``connected`` flag.
        if (
            self.connected
            or self.network_consumer_task is not None
            or self.network_producer_task is not None
            or getattr(self, "reader", None) is not None
            or getattr(self, "writer", None) is not None
        ):
            await self.close()
        self._shutting_down = False
        self._connection_lost = False
        try:
            connection = asyncio.open_connection(host=self.host, port=self.port, flags=socket.TCP_NODELAY)
            self.reader, self.writer = await asyncio.wait_for(connection, timeout=self.connect_timeout)
        except OSError as e:
            raise CommunicationError(f"Error connecting to {self.host}:{self.port}") from e
        self.network_consumer_task = asyncio.create_task(self._task_network_consumer(), name="network_consumer")
        self.network_producer_task = asyncio.create_task(self._task_network_producer(), name="network_producer")
        # asyncio.create_task(self._task_dump_queues_to_files(), name='dump_queues_to_files'),
        self.connected = True
        _logger.info(f"Connection established to {self.host}:{self.port}")

    async def close(self):
        """Disconnect from the remote host and clean up tasks and queues."""
        self.connected = False
        self._shutting_down = True
        if self.tx_queue:
            while not self.tx_queue.empty():
                _, frame_sent, _ = self.tx_queue.get_nowait()
                if frame_sent:
                    frame_sent.cancel()
        if self.network_producer_task:
            self.network_producer_task.cancel()
        if hasattr(self, "writer") and self.writer:
            self.writer.close()
            try:
                await self.writer.wait_closed()
            except ConnectionResetError:
                pass
            del self.writer

        if self.network_consumer_task:
            self.network_consumer_task.cancel()
        if hasattr(self, "reader") and self.reader:
            self.reader.feed_eof()
            self.reader.set_exception(RuntimeError("cancelling"))
            del self.reader

        self.expected_responses = {}
        # self.debug_frames = {
        #     'all': Queue(maxsize=1000),
        #     'error': Queue(maxsize=1000),
        # }

    def _abort_connection(self, exc: ConnectionLost) -> None:
        """Tear down shared state after an UNEXPECTED connection drop (#356).

        Called by whichever network task notices death (reader EOF, writer
        closing, stalled drain) — and by the send path's stuck-producer
        safety net. Idempotent: the ``_connection_lost`` flag guards re-entry,
        and every operation is a no-op the second time. Intentional shutdown
        (``close()``) early-returns: the #50 quiet paths stay close()'s job.

        Deliberately does NOT touch reader/writer/task attributes — connect()'s
        leftover-check and close() own that cleanup (#274 atomicity).
        """
        if self._shutting_down or self._connection_lost:
            return
        self._connection_lost = True
        self.connected = False
        # Unblock in-flight senders awaiting a response.
        aborted = 0
        for fut in self.expected_responses.values():
            if not fut.done():
                fut.set_exception(exc)
                aborted += 1
        self.expected_responses = {}
        # Unblock senders awaiting frame-sent for still-queued frames.
        drained = 0
        while not self.tx_queue.empty():
            _, frame_sent, response_future = self.tx_queue.get_nowait()
            for queued_fut in (frame_sent, response_future):
                if queued_fut is not None and not queued_fut.done():
                    queued_fut.set_exception(exc)
            drained += 1
        # Cancel the sibling task; the noticing task (if any) exits on its own.
        current = asyncio.current_task()
        for task in (self.network_consumer_task, self.network_producer_task):
            if task is not None and task is not current and not task.done():
                task.cancel()
        _logger.debug(
            "Connection teardown: aborted %d in-flight request(s), drained %d queued frame(s) (%s)",
            aborted,
            drained,
            exc,
        )

    async def _probe(self, request: TransparentRequest, timeout: float, retries: int) -> bool:
        """Send a request; return True on success, False on TimeoutError.

        Uses ``retry_delay=0`` so absent-device probes don't pay the silent-
        window-survival cost — detect() does many of these and most are
        expected to fail.
        """
        try:
            await self.send_request_and_await_response(
                request, timeout=timeout, retries=retries, retry_delay=0, warn_timeout=False
            )
            return True
        except TimeoutError:
            return False

    async def _probe_ranges(
        self,
        ranges: list[ProbeRange],
        timeout: float,
        retries: int,
        probe_timeout: float,
        probe_retries: int,
    ) -> None:
        """Issue each ProbeRange in order by tier; mark_absent on probe-tier failures."""
        for pr in ranges:
            req_cls = ReadHoldingRegistersRequest if pr.reg_type == "HR" else ReadInputRegistersRequest
            request = req_cls(
                base_register=pr.base_register,
                register_count=pr.register_count,
                device_address=pr.device_address,
            )
            if pr.tier == "known":
                await self.send_request_and_await_response(request, timeout=timeout, retries=retries)
            else:
                ok = await self._probe(request, timeout=probe_timeout, retries=probe_retries)
                _logger.debug(
                    "_probe_ranges: 0x%02x %s(%d,%d) → %s",
                    pr.device_address,
                    pr.reg_type,
                    pr.base_register,
                    pr.register_count,
                    "present" if ok else "absent",
                )
                if not ok:
                    self.plant.mark_absent(pr.device_address, pr.reg_type, pr.base_register, pr.register_count)
                    self.plant.register_caches.pop(pr.device_address, None)

    async def _detect_bcu_stacks(
        self,
        caps: PlantCapabilities,
        prior: PlantCapabilities | None,
        probe_timeout: float,
        probe_retries: int,
    ) -> None:
        """Populate caps.bcu_stacks. Hinted mode trusts prior layout; cold mode reads BMS at 0xA0."""
        if prior is not None:
            # Hinted: probe each previously-seen BCU and record what the BCU actually
            # reports for its module count (rather than trusting `prior`). The probe
            # populates IR(60–64) into the register cache; IR(64) is the BCU's own
            # module count. Letting actual values flow into `caps` here means a
            # change in stack composition is surfaced by the subsequent comparison
            # against `prior` rather than silently accepted. BMS read at 0xA0 is
            # skipped entirely — prior already tells us which BCUs to look at.
            for offset, _stored_modules in prior.bcu_stacks:
                if await self._probe(
                    ReadInputRegistersRequest(base_register=60, register_count=5, device_address=0x70 + offset),
                    timeout=probe_timeout,
                    retries=probe_retries,
                ):
                    bcu_cache = self.plant.register_caches.get(0x70 + offset, RegisterCache())
                    actual_modules = _bcu_module_count(bcu_cache)
                    caps.bcu_stacks.append((offset, actual_modules))
                else:
                    self.plant.mark_absent(0x70 + offset, "IR", 60, 5)
                    self.plant.register_caches.pop(0x70 + offset, None)
            return

        # Cold path: ask the BMS how many BCUs exist, then probe each.
        # 0xA0 is the BMS device address; IR(61) holds the number of BCUs present.
        if not await self._probe(
            ReadInputRegistersRequest(base_register=60, register_count=5, device_address=0xA0),
            timeout=probe_timeout,
            retries=probe_retries,
        ):
            self.plant.mark_absent(0xA0, "IR", 60, 5)
            self.plant.register_caches.pop(0xA0, None)
            return
        bms_cache: RegisterCache = self.plant.register_caches.get(0xA0, RegisterCache())
        num_bcus = _bms_bcu_count(bms_cache)
        for i in range(num_bcus):
            if await self._probe(
                ReadInputRegistersRequest(base_register=60, register_count=60, device_address=0x70 + i),
                timeout=probe_timeout,
                retries=probe_retries,
            ):
                bcu_cache = self.plant.register_caches.get(0x70 + i, RegisterCache())
                num_modules = _bcu_module_count(bcu_cache)
                caps.bcu_stacks.append((i, num_modules))
            else:
                self.plant.mark_absent(0x70 + i, "IR", 60, 60)
                self.plant.register_caches.pop(0x70 + i, None)

    #: Maximum number of battery modules on a single-BCU AIO (addresses 0x50–0x53).
    _AIO_MAX_MODULES = 4

    async def _ems_rollup_cross_check(self, timeout: float, retries: int) -> None:
        """Read IR(2040,55) at detect time and sanity-check the per-managed-inverter rollup.

        Populating the rollup during discovery means consumers don't need to
        wait for the first refresh cycle to see per-managed-inverter and
        per-meter data. The sanity check catches malformed rollups (or
        parser regressions) early.

        Best-effort end-to-end: a timeout on the read, or any anomaly during
        validation, only logs a warning — discovery never fails on this soft
        data check. See #95.
        """
        try:
            await self.send_request_and_await_response(
                ReadInputRegistersRequest(base_register=2040, register_count=55, device_address=0x11),
                timeout=timeout,
                retries=retries,
            )
        except TimeoutError:
            _logger.warning("detect: EMS rollup read at IR(2040,55) timed out — skipping cross-check")
            return
        self._validate_ems_rollup()

    def _validate_ems_rollup(self) -> None:
        """Sanity-check the EMS IR(2040,55) rollup decoded into the inverter's register cache.

        Logs warnings for any anomaly (no data, decode failure, implausible
        ``inverter_count``, malformed serial strings) but never raises —
        ``detect()`` shouldn't fail discovery on a soft data check. The
        intent is to surface parser regressions early without breaking the
        rest of the discovery flow.
        """
        cache = self.plant.register_caches.get(0x11)
        # EMS data is served at 0x11 (the rollup read above targets it, and Step 1's
        # HR(0,60) read populated the same cache). `cache is None` is therefore
        # unreachable in practice — the meaningful check is whether the rollup's IR
        # registers actually landed. `RegisterCache` is a defaultdict returning 0 for
        # missing keys, so without this guard a silently-failed rollup read would decode
        # as inverter_count=0 and mis-fire the implausible-count warning.
        if cache is None or IR(2040) not in cache:
            _logger.warning("detect: EMS rollup read returned no data at 0x11 — skipping cross-check")
            return
        try:
            ems = EmsRegisterGetter(cache).build()
        except Exception as e:  # noqa: BLE001 — best-effort sanity check, log and move on
            _logger.warning("detect: EMS rollup decode failed during cross-check: %s", e)
            return
        inverter_count = ems.get("inverter_count")
        if inverter_count is None or not (0 < inverter_count <= 4):
            _logger.warning(
                "detect: EMS rollup reports implausible inverter_count=%r (expected 1..4)",
                inverter_count,
            )
            return
        serials: list[str | None] = []
        for i in range(1, inverter_count + 1):
            raw = ems.get(f"inverter_{i}_serial_number")
            # Decoded serial fields can carry trailing NUL or space padding when the
            # underlying registers were partially populated; strip before matching so
            # a padded-but-valid serial doesn't fire a false warning.
            cleaned = raw.strip("\x00 ") if isinstance(raw, str) else raw
            serials.append(cleaned)
            if not (isinstance(cleaned, str) and _GE_SERIAL_STR_PATTERN.fullmatch(cleaned)):
                _logger.warning(
                    "detect: EMS rollup inverter_%d_serial_number=%r doesn't match GE serial format",
                    i,
                    cleaned,
                )
        # Decoded serials carry identifying information; keep them out of INFO-level
        # application logs to stay consistent with the wire-capture redaction posture
        # (`redact()` / PR #99). The per-slot WARNING already surfaces anomalies.
        _logger.debug(
            "detect: EMS rollup cross-check — inverter_count=%d, serials=[%s]",
            inverter_count,
            ", ".join(repr(s) for s in serials),
        )

    async def detect(
        self,
        timeout: float = 2.0,
        retries: int = 3,
        probe_timeout: float = 0.5,
        probe_retries: int = 1,
        prior: PlantCapabilities | None = None,
    ) -> PlantCapabilities:
        """Discover device type and peripheral topology.

        Reads HR(0) and HR(21) from the inverter to resolve the model, then
        probes for BCUs (HV systems), meters, and LV battery devices.

        Both returns the PlantCapabilities instance and assigns it to
        `self.plant.capabilities` — the returned object and the one stored on
        the plant are the same. Subsequent calls to Client.refresh() and
        Client.load_config() will use it automatically.

        When `prior` is supplied, the probe sweep restricts itself to the
        addresses listed in it — empty addresses from a cold sweep are skipped.
        If reality doesn't match prior (device_type changed, or any hinted
        address fails to confirm), raises PlantTopologyMismatch and leaves
        `self.plant.capabilities` as None. The exception carries `prior` and
        `actual` so callers can decide whether to retry, fall back to a cold
        detect(), or surface the change to the user.

        Uses a two-tier timeout: `timeout`/`retries` for the known inverter device
        (where a response is expected), and `probe_timeout`/`probe_retries` for
        speculative probes where absence is the common case.

        On a connection-level failure (TimeoutError / CommunicationError) the
        connection is torn down via close(), so connect()+detect() is atomic:
        `connected` flips to False and the standard "reconnect if not connected"
        idiom recovers (#274). A PlantTopologyMismatch is raised on a healthy
        connection (only the hint was wrong) and leaves it up so the caller can
        retry a cold detect().
        """
        try:
            return await self._detect(
                timeout=timeout,
                retries=retries,
                probe_timeout=probe_timeout,
                probe_retries=probe_retries,
                prior=prior,
            )
        except PlantTopologyMismatch:
            # Healthy connection — only the hint was wrong; capabilities already cleared.
            raise
        except (TimeoutError, CommunicationError):
            # A connection-level failure leaves a half-open socket with capabilities
            # unset. Tear down so connect()+detect() is atomic (#274). Guard close()
            # so a teardown error (e.g. a flaky writer.wait_closed()) can't mask the
            # original failure we're propagating.
            try:
                await self.close()
            except Exception:
                _logger.exception("detect: error during connection teardown after failure")
            raise

    async def probe_alive(self, timeout: float = 2.0, retries: int = 0) -> bool:
        """Cheap reconnect liveness gate: read HR(0) once and report whether the inverter answered.

        Distinct from :meth:`detect` by design. Its ONLY job is "is the inverter responding at
        all?", so on a hung dongle it fails fast (default ``retries=0``, no peripheral sweep) and
        its failure is free — the caller just probes again next tick. On the first success the
        caller runs a full, robust ``detect(retries=3)`` to re-establish topology; that detect
        stays robust because it no longer carries the probe's fail-fast constraint (which is the
        whole reason this is a separate method rather than ``detect(retries=0)`` — one call cannot
        be both the cheap gate and the robust recovery, since the identity read shares a single
        ``retries``).

        Mirrors :meth:`detect`'s connection discipline, returning a bool instead of raising:

        - **Alive** (HR(0) came back): the socket is left **open**, so the caller's follow-up
          ``detect`` reuses the same live connection.
        - **Not alive** (timeout / :class:`CommunicationError`, or a response that left no usable
          HR(0)): the socket is **closed** (mirroring detect's #274 teardown), releasing it for the
          dongle's quiet window so the coordinator uniformly reconnects next tick. Never raises.

        Reuses the exact HR(0,60)@0x11 read :meth:`detect` uses (the read proven against real
        hardware). Reconnect cadence/backoff stays the caller's concern (#356): this owns only the
        single check.
        """
        # Liveness must come from THIS probe's fresh response, not any cached HR(0): a
        # stale HR(0) from an earlier healthy connection would otherwise make a fresh
        # non-committing probe look alive (e.g. the dongle returns an all-zero bank that
        # _commit_bank rejects while the read still returns — Codex review). The block's
        # ingestion timestamp is stamped ONLY on a successful commit (which applies every
        # guard: Modbus error, CRC, all-zero rejection), so "did this probe re-stamp the
        # HR(0,60) block" is the honest, guard-delegating liveness signal.
        block = (0x11, "HR", 0, 60)
        stamped_before = self.plant.register_block_updated_at.get(block)
        try:
            await self.send_request_and_await_response(
                ReadHoldingRegistersRequest(base_register=0, register_count=60, device_address=0x11),
                timeout=timeout,
                retries=retries,
            )
            stamped_after = self.plant.register_block_updated_at.get(block)
            alive = stamped_after is not None and stamped_after != stamped_before
        except (TimeoutError, CommunicationError):
            alive = False
        if not alive:
            # Release the socket on any not-alive outcome, mirroring detect()'s teardown so a
            # failed liveness check leaves no half-open connection. Guard close() so a teardown
            # error can't turn a clean False into an exception.
            try:
                await self.close()
            except Exception:
                _logger.exception("probe_alive: error during connection teardown after failed liveness probe")
        return alive

    async def _detect_lv_batteries(
        self,
        prior: PlantCapabilities | None,
        timeout: float,
        retries: int,
        probe_timeout: float,
        probe_retries: int,
    ) -> None:
        """Populate register caches for LV battery addresses (detect step 4).

        Battery pack #1 is at 0x32, additional packs at 0x33–0x37 (the inverter lives at 0x11, not
        0x32 — issues #119/#189). Per-slot (not break-on-fail) like the meter sweep: addresses can be
        non-contiguous and a transient BMS timeout on pack N must not drop pack N+1 onward.
        is_valid() gating and caps population are handled by _derive_capabilities.
        """
        batt_candidates = prior.lv_battery_addresses if prior is not None else _COLD_LV_BATTERY_RANGE
        # The 0x32 preamble keeps the patient known-tier treatment (the primary pack matters — the
        # #350 family), but silence is NOT fatal (#358): gateways keep battery data register-embedded
        # in the rollup, and a hybrid with no pack attached is a valid AC/PV-only plant. Skipped
        # entirely when prior capabilities carry no 0x32 (a hinted gateway reconnect would otherwise
        # stall the full timeout on a read that can never answer, every reconnect).
        if 0x32 in batt_candidates:
            try:
                await self.send_request_and_await_response(
                    ReadInputRegistersRequest(base_register=60, register_count=60, device_address=0x32),
                    timeout=timeout,
                    retries=retries,
                    warn_timeout=False,  # expected-absence probe semantics: don't pollute retry counters
                )
                # #352/#289: since #352 a caps-absent 0x32 read routes through the battery getter, so
                # its first preamble frame is held by the cold-start splice guard (the cache stays empty
                # pending a corroborating re-read) exactly like 0x33+. detect() reads each address once,
                # so without this confirming read the primary pack is dropped at the _derive_capabilities
                # gate below and refresh() never re-polls it. One healthy re-read corroborates and
                # commits; a flapping/spliced bank fails to corroborate and correctly stays out (#289
                # anti-poison intact).
                if not self.plant.register_caches.get(0x32):
                    await self.send_request_and_await_response(
                        ReadInputRegistersRequest(base_register=60, register_count=60, device_address=0x32),
                        timeout=timeout,
                        retries=retries,
                        warn_timeout=False,
                    )
            except ConnectionLost:
                raise  # a dead connection is not an absent battery (#356 dual-base ordering)
            except TimeoutError:
                _logger.info("No LV battery answered at 0x32 — valid for gateways and battery-less plants (#358)")
                self.plant.mark_absent(0x32, "IR", 60, 60)
                self.plant.register_caches.pop(0x32, None)
            else:
                if not self.plant.register_caches.get(0x32):
                    self.plant.mark_absent(0x32, "IR", 60, 60)
        for batt_addr in batt_candidates:
            if batt_addr > 0x32:
                if not await self._probe(
                    ReadInputRegistersRequest(base_register=60, register_count=60, device_address=batt_addr),
                    timeout=probe_timeout,
                    retries=probe_retries,
                ):
                    self.plant.mark_absent(batt_addr, "IR", 60, 60)
                    self.plant.register_caches.pop(batt_addr, None)
                    continue
                # #233/#289: the first battery bank against an empty cache is held by the cold-start
                # splice guard (the cache stays empty pending a corroborating re-read). detect()
                # probes each address once, so without this confirming read the address is dropped at
                # the gate below and refresh() never re-polls it — a permanent hold for a recovered/
                # returned pack. One healthy re-read corroborates and commits; a flapping/spliced bank
                # fails to corroborate and correctly stays out (#289 anti-poison intact). Removed once
                # #213's placeholder model decouples enumeration from data adoption.
                if not self.plant.register_caches.get(batt_addr):
                    await self._probe(
                        ReadInputRegistersRequest(base_register=60, register_count=60, device_address=batt_addr),
                        timeout=probe_timeout,
                        retries=probe_retries,
                    )
                if not self.plant.register_caches.get(batt_addr):
                    self.plant.mark_absent(batt_addr, "IR", 60, 60)

    async def _detect(
        self,
        timeout: float,
        retries: int,
        probe_timeout: float,
        probe_retries: int,
        prior: PlantCapabilities | None,
    ) -> PlantCapabilities:
        """Implementation of detect(); see detect() for the contract and error semantics."""
        if prior is not None:
            _logger.info(
                "detect: hinted mode — assuming device_type=Model.%s, inverter=0x%02x, "
                "meters=[%s], lv_batteries=[%s], bcus=[%s], lv_bcu=%s",
                prior.device_type.name,
                prior.inverter_address,
                ", ".join(f"0x{a:02x}" for a in prior.meter_addresses),
                ", ".join(f"0x{a:02x}" for a in prior.lv_battery_addresses),
                ", ".join(f"0x{0x70 + offset:02x} (x{n})" for offset, n in prior.bcu_stacks),
                f"0x{prior.lv_bcu_address:02x}" if prior.lv_bcu_address is not None else "None",
            )

        # Step 1 — read the inverter's configuration block to get DTC and ARM firmware.
        # 0x11 is the inverter's canonical address for every model (#189); discovery reads
        # there and the response is cached under 0x11 (issue #119). resolve_model() below maps
        # the DTC to the model; PlantCapabilities derives the same 0x11 for later polling.
        await self.send_request_and_await_response(
            ReadHoldingRegistersRequest(base_register=0, register_count=60, device_address=0x11),
            timeout=timeout,
            retries=retries,
        )
        cache: RegisterCache = self.plant.register_caches.get(0x11, RegisterCache())
        raw_dtc = cache.get(HR(0))
        if raw_dtc is None:
            raise CommunicationError(
                "detect: HR(0) not populated after reading device 0x11 — cannot determine device type"
            )
        arm_fw = cache.get(HR(21)) or 0
        # arm_firmware_version here is consistency-only: this intermediate `caps` only
        # feeds the probing steps below (is_hv, device_type), none of which read
        # firmware. The object detect() actually returns is built separately in
        # _derive_capabilities() (#293 Slice B), which populates the field for real.
        caps = PlantCapabilities(device_type=resolve_model(raw_dtc, arm_fw), arm_firmware_version=arm_fw or None)
        _logger.info("detect: device_type=Model.%s", caps.device_type.name)

        if prior is not None and prior.device_type != caps.device_type:
            self.plant.capabilities = None
            raise PlantTopologyMismatch(
                f"detect: device_type changed since prior capture "
                f"(prior={prior.device_type}, actual={caps.device_type}) — discarding hint",
                prior=prior,
                actual=caps,
            )

        # Step 2 — BCU probing for HV systems.
        if caps.is_hv:
            await self._detect_bcu_stacks(caps, prior, probe_timeout, probe_retries)
            _logger.info(
                "detect: bcu_stacks=[%s]",
                ", ".join(f"0x{0x70 + o:02x} (x{n})" for o, n in caps.bcu_stacks),
            )

        # Step 2b — AIO per-module battery probing (#192). The All-in-One exposes each
        # battery module at its own device address (0x50+), distinct from the bcu_stacks
        # stride layout, so its per-module cell/temperature/serial data is reachable.
        if caps.device_type is Model.ALL_IN_ONE and caps.bcu_stacks:
            _offset, num_modules = caps.bcu_stacks[0]
            if num_modules > self._AIO_MAX_MODULES:
                _logger.warning(
                    "detect: BCU reports %d modules but AIO maximum is %d — clamping",
                    num_modules,
                    self._AIO_MAX_MODULES,
                )
            await self._probe_ranges(
                _strategise(caps, prior, "aio_modules"), timeout, retries, probe_timeout, probe_retries
            )

        # Step 2c — HV BMU per-module probing (#265). Non-AIO HV stacks expose per-cell data at
        # their own BMU addresses (0x50+), distinct from the bcu_stacks stride decode (which read
        # the BCU's cluster registers as cells). Self-gated inside _strategise to non-AIO HV.
        await self._probe_ranges(_strategise(caps, prior, "hv_bmus"), timeout, retries, probe_timeout, probe_retries)

        # Step 3 — meter probing. Hinted: only previously-seen addresses. Cold: full 0x01–0x08 sweep.
        # In both modes, a probe response is necessary but not sufficient — some EMS firmwares
        # ACK every slot in 0x01..0x08 with all-zero registers regardless of whether a meter is
        # actually wired. Validate via Meter.is_valid() to filter those ghosts (in the validate step
        # below). Per-slot (not break-on-fail): meters can be non-contiguous. See #95.
        await self._probe_ranges(_strategise(caps, prior, "meters"), timeout, retries, probe_timeout, probe_retries)

        # Step 4 — LV battery + LV BCU detection. Skipped for HV systems (handled at step 2) and
        # EMS plant controllers (don't expose IR at the inverter address — see #86).
        if not caps.is_hv and not caps.is_ems:
            # _detect_lv_batteries stays imperative: it issues a known-tier preamble read
            # at 0x32 and contains the cold-start splice-guard reprobe (#233/#289/#213).
            await self._detect_lv_batteries(prior, timeout, retries, probe_timeout, probe_retries)

            # Step 4b — LV BCU page probe (#241).
            await self._probe_ranges(_strategise(caps, prior, "lv_bcu"), timeout, retries, probe_timeout, probe_retries)

        # Step 5 — EMS rollup cross-check. See `_ems_rollup_cross_check()` for the contract.
        if caps.is_ems:
            await self._ems_rollup_cross_check(timeout=timeout, retries=retries)

        # Validate: derive the authoritative capabilities from the now-populated register_caches.
        # is_valid() gating, mark_absent on invalid, and all candidate logic live in _derive_capabilities;
        # no duplicated enumeration here. on_reject threads mark_absent into the validate step.
        final_caps = _derive_capabilities(self.plant.register_caches, prior, on_reject=self.plant.mark_absent)
        _logger.info(
            "detect: meters=[%s], lv_batteries=[%s], lv_bcu=%s, aio_modules=[%s], hv_bmus=[%s]",
            ", ".join(f"0x{a:02x}" for a in final_caps.meter_addresses),
            ", ".join(f"0x{a:02x}" for a in final_caps.lv_battery_addresses),
            f"0x{final_caps.lv_bcu_address:02x}" if final_caps.lv_bcu_address is not None else "None",
            ", ".join(f"0x{a:02x}" for a in final_caps.aio_battery_module_addresses),
            ", ".join(f"0x{a:02x}" for a in final_caps.hv_bmu_addresses),
        )

        # arm_firmware_version is informational, not part of the topology contract — a
        # firmware upgrade on otherwise-unchanged hardware must not raise a mismatch
        # here (#293 Slice B added the field; excluding it from this comparison keeps
        # PlantTopologyMismatch scoped to genuine address/device-type/count changes).
        if prior is not None and prior.model_dump(exclude={"arm_firmware_version"}) != final_caps.model_dump(
            exclude={"arm_firmware_version"}
        ):
            self.plant.capabilities = None
            raise PlantTopologyMismatch(
                f"detect: plant topology does not match prior — prior={prior!r}, actual={final_caps!r}",
                prior=prior,
                actual=final_caps,
            )

        self.plant.capabilities = final_caps
        return final_caps

    async def _execute_reads(
        self,
        requests: list[TransparentRequest],
        *,
        timeout: float,
        retries: int,
        retry_delay: float,
    ) -> None:
        """Run a batch of register reads, tolerating partial failure.

        Successful reads have already been written to the register caches by the
        network consumer task, so this only decides how to *signal* the failures:

        - no failures → return (the caller returns the populated plant);
        - some failed → raise ``RefreshPartiallySucceeded`` carrying the partial
          plant plus the structured failures — the caller's one chance to use
          the data that did come back;
        - all failed → raise ``RefreshFailed`` (link effectively dead).
        """
        if not requests:
            return
        results = await self.execute(
            requests, timeout=timeout, retries=retries, retry_delay=retry_delay, return_exceptions=True
        )
        failures: list[ReadFailure] = []
        causes: list[Exception] = []
        for req, res in zip(requests, results, strict=True):
            if isinstance(res, Exception):
                # base_register/register_count live on read requests, which is all
                # _execute_reads is ever handed; getattr keeps mypy happy without a
                # never-taken else branch.
                failures.append(
                    ReadFailure(
                        req.device_address,
                        type(req).__name__,
                        getattr(req, "base_register", 0),
                        getattr(req, "register_count", 0),
                    )
                )
                causes.append(res)
            elif isinstance(res, BaseException):
                # Control-flow exceptions (e.g. CancelledError) must never be swallowed.
                raise res
        if not failures:
            return
        group = ExceptionGroup(f"{len(failures)}/{len(requests)} register reads failed", causes)
        summary = ", ".join(f"{f.request_type}(0x{f.device_address:02x},{f.base_register})" for f in failures)
        if len(failures) == len(requests):
            _logger.warning("All %d register reads failed; treating plant as unreachable", len(requests))
            raise RefreshFailed(f"all {len(requests)} register reads failed", failures=failures, cause=group)
        _logger.warning("%d of %d register reads failed: %s", len(failures), len(requests), summary)
        raise RefreshPartiallySucceeded(
            f"{len(failures)} of {len(requests)} register reads failed",
            plant=self.plant,
            failures=failures,
            cause=group,
        )

    async def load_config(self, timeout: float = 2.0, retries: int = 3, retry_delay: float = 0.5) -> Plant:
        """Read HR configuration blocks for the inverter.

        Returns the populated plant on full success. On partial/total read
        failure raises ``RefreshPartiallySucceeded`` / ``RefreshFailed``.

        Success does not imply *fresh*: the keep-last-good guards (CRC #255, sub-bus
        splice #256, bank holds) report a successful poll while serving last-known-good
        content for a device whose live read was rejected. Display consumers should gate
        on ``Plant.register_age()`` / ``Plant.block_age()``, not on a poll returning.
        """
        caps = self.plant.capabilities
        if caps is None:
            raise PlantNotDetected(
                "load_config() requires plant capabilities — call detect() once first, "
                "or restore a persisted PlantCapabilities onto client.plant.capabilities."
            )
        inverter = caps.inverter_address
        is_ems = caps.is_ems
        # HR(0,60) is the identity/firmware/serial bank that every device type — including EMS —
        # answers; it's the same bank detect() reads to identify the device. The HR(60,60),
        # HR(120,60) and IR(120,60) banks are inverter-specific; EMS plant controllers don't
        # expose them and the reads time out every poll. The EMS's own window at HR(2040,36)
        # is covered by the EMS-conditional append below. See #86 (wire capture confirmed via
        # dewet22/givenergy-hass#52).
        reqs: list[TransparentRequest] = [
            ReadHoldingRegistersRequest(base_register=0, register_count=60, device_address=inverter),
        ]
        if not is_ems:
            reqs += [
                ReadHoldingRegistersRequest(base_register=60, register_count=60, device_address=inverter),
                ReadHoldingRegistersRequest(base_register=120, register_count=60, device_address=inverter),
                ReadInputRegistersRequest(base_register=120, register_count=60, device_address=inverter),
            ]
        if caps.is_three_phase:
            reqs += [_request_for_range(r, inverter) for r in manifest.LOAD_CONFIG_THREE_PHASE_RANGES]
        if caps.has_extended_slots:
            reqs.append(ReadHoldingRegistersRequest(base_register=240, register_count=60, device_address=inverter))
        # getattr(caps, name), not manifest.gated_ranges(...): the latter reads
        # manifest.CAPABILITIES directly and is blind to PropertyMock-patched
        # instance properties, which two tests rely on for facts with no confirmed
        # model yet (has_hv_cabinet_block/has_peak_shaving_block, #293). Every
        # LOAD_CONFIG_RANGES fact today is firmware-independent, so this is safe;
        # a future firmware-gated fact here would need manifest.gated_ranges(...,
        # caps.arm_firmware_version) instead, since getattr() can't pass arm_fw.
        for name, entries in manifest.LOAD_CONFIG_RANGES.items():
            if getattr(caps, name):
                reqs += [_request_for_range(r, inverter) for r in entries]
        await self._execute_reads(reqs, timeout=timeout, retries=retries, retry_delay=retry_delay)
        return self.plant

    async def refresh(
        self,
        timeout: float = 2.0,
        retries: int = 1,
        retry_delay: float = 0.5,
        ir0_max_age: float | None = None,
        *,
        max_age: float | None = None,
    ) -> Plant:
        """Read IR measurement blocks for all known devices.

        Returns the populated plant on full success. On partial/total read
        failure raises ``RefreshPartiallySucceeded`` / ``RefreshFailed``.

        Success does not imply *fresh*: the keep-last-good guards (CRC #255, sub-bus
        splice #256, bank holds) report a successful poll while serving last-known-good
        content for a device whose live read was rejected. Display consumers should gate
        on ``Plant.register_age()`` / ``Plant.block_age()``, not on a poll returning.

        The ``timeout=2.0, retries=1`` defaults are tuned for a contended bus: the
        inverter serialises requests, so when other clients (GivTCP, the vendor app,
        Predbat) poll the same unit a tighter budget produces spurious timeouts even
        though the device is responsive (#132). Pass a tighter budget if you own the
        bus exclusively and want genuine failures surfaced faster.

        ``max_age`` (seconds) opts in to skip-if-fresh for any IR bank (#196, #207):
        GivEnergy dongles fan out the responses to whoever is polling them (the cloud,
        the app, another client), so the consumer often already has recent data in cache
        without us asking. When set, any IR bank committed within ``max_age`` seconds
        is not re-solicited this cycle. Defaults to ``None`` — always solicit, the
        historic behaviour. Note the fan-out only exists while something else is polling
        the unit; on a cloud-disconnected dongle the blocks age out and we solicit them.
        The fan-out is opportunistic, not a promise that concurrent access is free —
        some dongles idle-reap the connection under multiple clients (hass#95) and
        reconnect transparently; the skip degrades safely (blocks age out, we solicit).

        ``ir0_max_age`` is deprecated — use ``max_age`` instead. It applied the same
        logic to IR(0,60) only; ``max_age`` extends it to every bank. Will be removed
        in 3.0.
        """
        caps = self.plant.capabilities
        if caps is None:
            raise PlantNotDetected(
                "refresh() requires plant capabilities — call detect() once first, "
                "or restore a persisted PlantCapabilities onto client.plant.capabilities."
            )
        if ir0_max_age is not None:
            warnings.warn(
                "refresh(ir0_max_age=...) is deprecated; use max_age= instead "
                "(applies to all banks, not just IR(0,60)). ir0_max_age will be "
                "removed in 3.0.",
                DeprecationWarning,
                stacklevel=2,
            )
            if max_age is None:
                max_age = ir0_max_age
        await self._execute_reads(
            _refresh_ranges(caps, max_age, self.plant),
            timeout=timeout,
            retries=retries,
            retry_delay=retry_delay,
        )
        return self.plant

    async def refresh_plant(
        self,
        full_refresh: bool = True,
        max_batteries: int = 5,
        timeout: float = 2.0,
        retries: int = 1,
        retry_delay: float = 0.5,
    ) -> Plant:
        """Deprecated orchestrator — run ``detect()`` once, then drive your own loop.

        .. deprecated::
            Will be removed in 3.0 (soon). This composes ``detect()`` (when needed) +
            ``load_config()`` + ``refresh()``, which is trivial to do in the consumer
            where the partial-failure policy belongs. It propagates
            ``RefreshPartiallySucceeded`` / ``RefreshFailed`` like the primitives —
            note that on a full refresh a partial failure in ``load_config()``
            short-circuits before ``refresh()`` runs; call the primitives directly for
            full control.

            Unlike the primitives, this wrapper runs ``detect()`` for you if
            capabilities are absent (preserving the legacy connect-then-refresh shape).
            New code should call ``detect()`` then ``load_config()`` / ``refresh()``
            directly — the primitives raise ``PlantNotDetected`` rather than guessing
            an address.
        """
        warnings.warn(
            "Client.refresh_plant() is deprecated and will be removed in 3.0. Run detect() once, then "
            "drive your own poll loop over load_config()/refresh(). It now propagates "
            "RefreshPartiallySucceeded/RefreshFailed on partial/total read failure.",
            DeprecationWarning,
            stacklevel=2,
        )
        if max_batteries != 5:
            # Battery addresses now come from detect()/capabilities, so this argument
            # no longer does anything — warn rather than silently ignore a custom value.
            warnings.warn(
                "The max_batteries argument to refresh_plant() is ignored — battery "
                "addresses are now discovered by detect(). It will be removed with "
                "refresh_plant() in 3.0.",
                DeprecationWarning,
                stacklevel=2,
            )
        # The primitives require capabilities; as the legacy one-call wrapper, detect
        # them here if the caller hasn't, so connect()-then-refresh_plant() still works
        # (it now addresses correctly per model — issue #105, where an AIO answering at
        # 0x11 timed out under the old 0x32 fallback).
        if self.plant.capabilities is None:
            self.plant.capabilities = await self.detect(timeout=timeout, retries=retries)
        if full_refresh:
            await self.load_config(timeout=timeout, retries=retries, retry_delay=retry_delay)
        await self.refresh(timeout=timeout, retries=retries, retry_delay=retry_delay)
        return self.plant

    async def watch_plant(
        self,
        handler: Callable | None = None,
        refresh_period: float = 15.0,
        max_batteries: int = 5,
        timeout: float = 2.0,
        retries: int = 1,
        retry_delay: float = 0.5,
        passive: bool = False,
    ):
        """Deprecated poll loop — own the loop in the consumer instead.

        .. deprecated::
            Will be removed in 3.0. Connect, ``detect()``, then loop over
            ``load_config()`` / ``refresh()`` yourself, handling
            ``RefreshPartiallySucceeded`` / ``RefreshFailed`` as suits the consumer.
        """
        warnings.warn(
            "Client.watch_plant() is deprecated and will be removed in 3.0. Own your poll loop: "
            "connect(), detect(), then loop over load_config()/refresh() handling "
            "RefreshPartiallySucceeded/RefreshFailed as you see fit.",
            DeprecationWarning,
            stacklevel=2,
        )
        await self.connect()
        await self.refresh_plant(
            True,
            max_batteries=max_batteries,
            timeout=timeout,
            retries=retries,
            retry_delay=retry_delay,
        )
        while True:
            if handler:
                handler()
            await asyncio.sleep(refresh_period)
            if not passive:
                # Defer to refresh_plant so capability-aware polling (EMS, gateway,
                # three-phase, HV stacks, meters) is included on each tick rather
                # than the legacy single-phase IR(0)/IR(180) + battery shape.
                await self.refresh_plant(
                    full_refresh=False,
                    max_batteries=max_batteries,
                    timeout=timeout,
                    retries=retries,
                    retry_delay=retry_delay,
                )

    def _resolve_write_safe(self) -> frozenset[int]:
        """Registers the detected model permits at the normal (non-installer) write tier.

        The single source of the caps→manifest resolution shared by one_shot_command()
        and installer_command() — write-gating logic that must stay identical across
        both call sites. Composition (base selection, AC-config union with its
        not-three-phase guard, undetected→single-phase fallback) lives in
        manifest.write_safe_registers (#293 Slice D).
        """
        caps = self.plant.capabilities
        return manifest.write_safe_registers(
            caps.device_type if caps is not None else None,
            caps.arm_firmware_version if caps is not None else None,
        )

    async def one_shot_command(
        self,
        requests: list[TransparentRequest],
        timeout: float = 1.5,
        retries: int = 0,
        retry_delay: float = 0.5,
        dry_run: bool = False,
    ) -> None:
        """Execute write requests, validating each against the detected inverter model.

        Raises InvalidPduState for any write to a register not permitted for the
        detected model. When capabilities are not yet known, falls back to the
        universally-applicable single-phase register set (conservative).

        If dry_run is True, validates but does not transmit — running the same PDU
        validation (``ensure_valid_state``) the live encode path runs, so a dry run
        never passes for a request real execution would reject.
        """
        caps = self.plant.capabilities
        safe = self._resolve_write_safe()
        model_label = caps.device_type.name if caps is not None else "undetected"
        for req in requests:
            if isinstance(req, WriteHoldingRegisterRequest):
                if req.installer:
                    raise InvalidPduState(
                        f"HR({req.register}) is an installer-tier request; use installer_command() instead",
                        req,
                    )
                if req.register not in safe:
                    raise InvalidPduState(f"HR({req.register}) is not permitted for {model_label} inverter", req)
            # Run the same PDU-level validation encode() runs (value bounds, global
            # safe-register set), so dry-run and live paths reject identically.
            req.ensure_valid_state()
        if not dry_run:
            await self.execute(requests, timeout=timeout, retries=retries, retry_delay=retry_delay)

    async def installer_command(
        self,
        requests: list[TransparentRequest],
        timeout: float = 1.5,
        retries: int = 0,
        retry_delay: float = 0.5,
        dry_run: bool = False,
    ) -> None:
        """Execute installer-tier write requests.

        Like one_shot_command() but admits registers from INSTALLER_WRITE_REGISTERS.
        Requests must be constructed with installer=True via the dedicated helpers in
        client.commands (e.g. set_battery_nominal_power, restore_factory_defaults).

        one_shot_command() always rejects installer-flagged requests — the two methods
        are non-overlapping by design (dual-gate separation).

        If dry_run is True, validates but does not transmit.
        """
        caps = self.plant.capabilities
        model_safe = self._resolve_write_safe()
        installer_safe = model_safe | INSTALLER_WRITE_REGISTERS
        model_label = caps.device_type.name if caps is not None else "undetected"
        for req in requests:
            if isinstance(req, WriteHoldingRegisterRequest):
                effective_safe = installer_safe if req.installer else model_safe
                if req.register not in effective_safe:
                    raise InvalidPduState(f"HR({req.register}) is not permitted for {model_label} inverter", req)
            req.ensure_valid_state()
        if not dry_run:
            await self.execute(requests, timeout=timeout, retries=retries, retry_delay=retry_delay)

    def _emit_to_sink(self, direction: "Direction", data: bytes) -> None:
        """Hand redacted bytes to the active capture sink, swallowing sink errors.

        The sink is a user-supplied callback. It runs inside the long-lived network
        consumer/producer tasks (and the capture-close flush), so an exception it
        raises would otherwise crash that background task and break the client. A
        capture is a diagnostic tee, never load-bearing — log and carry on.
        """
        sink = self._capture_sink
        if sink is None or not data:
            return
        try:
            sink(direction, data)
        except Exception:  # noqa: BLE001 — a capture sink must never break the client
            _logger.exception("capture sink raised on %s frame; dropping it and continuing", direction)

    async def capture_frames(
        self,
        sink: Callable[[Direction, bytes], None],
        duration: float = 60.0,
    ) -> None:
        """Tee redacted TX/RX wire frames to *sink* for *duration* seconds.

        *sink* is called with the direction ('rx' or 'tx') and the redacted bytes.
        The library always redacts before invoking the sink so callers can't
        accidentally see raw hardware identifiers; persistence, formatting and
        forwarding are the caller's choice.

        Redaction is frame-aware: each complete GivEnergy frame is decoded, its
        serial-bearing fields (envelope serials, C.serial-tagged register values,
        LAN-config IPs) are zeroed by type, and the frame is re-encoded with a
        freshly-computed CRC. Frames that cannot be decoded (unknown function codes,
        malformed/truncated frames) are emitted intact with a log message — they are
        never dropped or mangled. The sink sees complete frames (one call per
        complete frame) rather than raw socket chunks.

        Runs alongside the normal refresh loop — does not suspend reads or writes,
        just tees a copy of each frame to *sink*. Only one capture may run on a
        Client at a time; calling while one is in flight raises RuntimeError.
        """
        if self._capture_sink is not None:
            raise RuntimeError("a frame capture is already running on this client")
        self._capture_sink = sink
        self._capture_redactor_rx = FrameRedactor("rx")
        self._capture_redactor_tx = FrameRedactor("tx")
        try:
            await asyncio.sleep(duration)
        finally:
            # Flush each direction's held tail so the final bytes aren't lost.
            for direction, redactor in (("rx", self._capture_redactor_rx), ("tx", self._capture_redactor_tx)):
                if redactor is not None:
                    self._emit_to_sink(direction, redactor.flush())  # type: ignore[arg-type]
            self._capture_sink = None
            self._capture_redactor_rx = None
            self._capture_redactor_tx = None

    def _note_eof_drop(self, now: datetime) -> tuple[bool, int]:
        """Coalesce a burst of reader-EOF reconnect churn; decide WARNING vs DEBUG (#355-style).

        Returns ``(warn_now, count_since_last_warn)``. The first drop of a burst, and the first
        drop past each ``_EOF_REWARN_SECONDS`` window within a *sustained* burst, warn and carry
        the running tally of drops since the previous warning; drops in between return
        ``(False, 0)`` for DEBUG.

        A burst is a run of drops each within ``_EOF_REWARN_SECONDS`` of the previous one. When
        the gap since the *last drop* exceeds the window the churn has clearly stopped, so the
        burst is closed and the next drop starts fresh (count 1) rather than escalating with a
        stale tally accumulated an hour ago — keeping the reported count honest (Codex note on
        #401). A genuinely fresh problem is therefore never swallowed; only sustained churn is
        throttled.
        """
        last_drop = self._eof_last_drop_at
        if last_drop is not None and (now - last_drop).total_seconds() >= _EOF_REWARN_SECONDS:
            # Preceding burst is over — reset so this drop is treated as a fresh onset.
            self._eof_drop_count = 0
            self._eof_last_warn_at = None
        self._eof_last_drop_at = now
        self._eof_drop_count += 1
        last_warn = self._eof_last_warn_at
        if last_warn is None or (now - last_warn).total_seconds() >= _EOF_REWARN_SECONDS:
            count = self._eof_drop_count
            self._eof_last_warn_at = now
            self._eof_drop_count = 0
            return True, count
        return False, 0

    async def _task_network_consumer(self):
        """Task for orchestrating incoming data."""
        while hasattr(self, "reader") and self.reader and not self.reader.at_eof():
            frame = await self.reader.read(300)
            if self._capture_sink is not None and frame and self._capture_redactor_rx is not None:
                self._emit_to_sink("rx", self._capture_redactor_rx.feed(frame))
            async for message in self.framer.decode(frame):
                _logger.debug(f"Processing {message}")
                if isinstance(message, ExceptionBase):
                    _logger.warning(f"Expected response never arrived but resulted in exception: {message}")
                    continue
                if isinstance(message, HeartbeatRequest):
                    _logger.debug("Responding to HeartbeatRequest")
                    await self.tx_queue.put((message.expected_response().encode(), None, None))
                    continue
                if not isinstance(message, TransparentResponse):
                    _logger.warning(f"Received unexpected message type for a client: {message}")
                    continue
                if isinstance(message, WriteHoldingRegisterResponse):
                    if message.error:
                        _logger.warning(f"{message}")
                    else:
                        _logger.info(f"{message}")

                # Update the plant cache *before* resolving the awaiting future so
                # the awaiter is guaranteed to see the updated cache regardless of
                # asyncio scheduling order. Today this happens to work either way
                # because nothing yields between set_result and plant.update, but
                # that's fragile to future refactors — make it explicit.
                self.plant.update(message)
                # Don't resolve the future for a discarded CRC-failed frame — leave it
                # pending so send_request_and_await_response's timeout/retry fires a fresh
                # request rather than treating a corrupt frame as a successful read.
                if getattr(message, "crc_failed", False) and not getattr(message, "lenient_crc_commit", False):
                    continue
                future = self.expected_responses.get(message.shape_hash(), None)
                if future and not future.done():
                    future.set_result(message)
        if self._shutting_down:
            _logger.debug("network_consumer exiting on intentional shutdown")
        else:
            self.connected = False
            warn_now, count = self._note_eof_drop(datetime.now(UTC))
            if warn_now and count > 1:
                _logger.warning(
                    "network_consumer: connection lost (reader at EOF) — %d drops since the previous "
                    "warning, recovering transparently each time",
                    count,
                )
            elif warn_now:
                _logger.warning("network_consumer: connection lost (reader at EOF)")
            else:
                _logger.debug("network_consumer: connection lost (reader at EOF) — coalesced, recovering transparently")
            self._abort_connection(ConnectionLost("reader at EOF — connection lost"))

    async def _task_network_producer(self):
        """Producer loop to transmit queued frames with an appropriate delay.

        Frames whose response_future is already done (i.e. resolved by a late
        arrival from a previous attempt that happened to arrive in the queueing
        window) are skipped — there's no point writing a request whose answer
        we already have. The frame_sent future is still signalled so the
        caller-side awaiter unblocks normally.

        Inter-frame sleep is ``tx_message_wait + uniform(0, tx_jitter)``. The
        jitter is asymmetric — it never reduces the gap below ``tx_message_wait``
        — so existing hardware-derived minimum spacing is preserved while
        coordinated bursts (polling ticks, retry storms) disperse naturally.
        """
        while hasattr(self, "writer") and self.writer and not self.writer.is_closing():
            message, frame_sent, response_future = await self.tx_queue.get()
            if response_future is not None and response_future.done():
                _logger.debug("Skipping wire send — response already resolved")
                self.tx_queue.task_done()
                if frame_sent and not frame_sent.done():
                    frame_sent.set_result(True)
                continue
            try:
                self.writer.write(message)
                if self._capture_sink is not None and self._capture_redactor_tx is not None:
                    self._emit_to_sink("tx", self._capture_redactor_tx.feed(message))
                await asyncio.wait_for(self.writer.drain(), timeout=_DRAIN_TIMEOUT)
            except TimeoutError:
                _logger.warning(
                    "network_producer: writer drain stalled >%.0fs — treating connection as lost",
                    _DRAIN_TIMEOUT,
                )
                exc = ConnectionLost("writer drain stalled — connection lost")
                # This frame is already dequeued, so the teardown's queue-drain
                # can't reach it — fail its futures here.
                for fut in (frame_sent, response_future):
                    if fut is not None and not fut.done():
                        fut.set_exception(exc)
                self.tx_queue.task_done()
                self._abort_connection(exc)
                return
            except OSError as e:
                _logger.warning(
                    "network_producer: socket error during write/drain (%s) — treating connection as lost", e
                )
                exc = ConnectionLost(f"socket error during write/drain — connection lost: {e}")
                # This frame is already dequeued, so the teardown's queue-drain
                # can't reach it — fail its futures here.
                for fut in (frame_sent, response_future):
                    if fut is not None and not fut.done():
                        fut.set_exception(exc)
                self.tx_queue.task_done()
                self._abort_connection(exc)
                return
            self.tx_queue.task_done()
            if frame_sent and not frame_sent.done():
                frame_sent.set_result(True)
            # B311: plain random is appropriate for non-cryptographic burst-dispersal jitter.
            await asyncio.sleep(self.tx_message_wait + random.uniform(0, self.tx_jitter))  # nosec B311
        if self._shutting_down:
            _logger.debug("network_producer exiting on intentional shutdown")
        else:
            self.connected = False
            _logger.warning("network_producer: connection lost (writer closing)")
            self._abort_connection(ConnectionLost("writer closing — connection lost"))

    # async def _task_dump_queues_to_files(self):
    #     """Task to periodically dump debug message frames to disk for debugging."""
    #     while True:
    #         await asyncio.sleep(30)
    #         if self.debug_frames:
    #             os.makedirs('debug', exist_ok=True)
    #             for name, queue in self.debug_frames.items():
    #                 if not queue.empty():
    #                     async with aiofiles.open(f'{os.path.join("debug", name)}_frames.txt', mode='a') as str_file:
    #                         await str_file.write(f'# {arrow.utcnow().timestamp()}\n')
    #                         while not queue.empty():
    #                             item = await queue.get()
    #                             await str_file.write(item.hex() + '\n')

    def execute(
        self,
        requests: list[TransparentRequest],
        timeout: float,
        retries: int,
        retry_delay: float = 0.5,
        return_exceptions: bool = False,
    ) -> Future[list[TransparentResponse]]:
        """Helper to perform multiple requests in bulk."""
        return asyncio.gather(  # type: ignore[return-value]
            *[
                self.send_request_and_await_response(m, timeout=timeout, retries=retries, retry_delay=retry_delay)
                for m in requests
            ],
            return_exceptions=return_exceptions,
        )

    async def send_request_and_await_response(  # noqa: C901
        self,
        request: TransparentRequest,
        timeout: float,
        retries: int,
        retry_delay: float = 0.5,
        warn_timeout: bool = True,
    ) -> TransparentResponse:
        """Send a request to the remote, await and return the response.

        On timeout, ``retry_delay`` seconds pass before the next attempt is
        enqueued. The default of 0.5s was chosen to overcome the multi-second
        silent-window failure mode observed in the field — firing the retry
        immediately tends to land it inside the same silent window as the
        original request, accomplishing nothing. Callers that want the
        original "retry immediately" behaviour (e.g. fast probes, latency-
        sensitive interactive commands) should pass ``retry_delay=0``.
        """
        if self._connection_lost:
            raise ConnectionLost("connection lost — reconnect before sending")

        # mark the expected response
        expected_response = request.expected_response()
        expected_shape_hash = expected_response.shape_hash()
        existing_response_future = self.expected_responses.get(expected_shape_hash, None)
        if existing_response_future and not existing_response_future.done():
            _logger.debug(f"Cancelling existing in-flight request and replacing: {request}")
            existing_response_future.cancel()

        raw_frame = request.encode()

        def _discard(fut: "Future[TransparentResponse]") -> None:
            # Abandon a future and remove its registration — but only if it's still the one
            # mapped under expected_shape_hash. A newer same-shaped caller may have replaced it
            # (see existing_response_future above); evicting that newer mapping would leave the
            # newer caller unable to receive its response.
            fut.cancel()
            if self.expected_responses.get(expected_shape_hash) is fut:
                del self.expected_responses[expected_shape_hash]

        tries = 0
        while tries <= retries:
            response_future: Future[TransparentResponse] = asyncio.get_running_loop().create_future()
            self.expected_responses[expected_shape_hash] = response_future
            frame_sent = asyncio.get_running_loop().create_future()
            try:
                await asyncio.wait_for(self.tx_queue.put((raw_frame, frame_sent, response_future)), timeout=5.0)
            except TimeoutError as exc:
                _discard(response_future)
                raise TimeoutError("TX queue full — producer task has likely died") from exc
            if self._connection_lost:
                # Lost the race with _abort_connection's queue-drain: our frame was
                # enqueued after the drain and will never be sent. _discard cancels the
                # response future, so a post-reconnect producer skips the stale frame
                # (the queue-front skip-if-resolved check).
                _discard(response_future)
                raise ConnectionLost("connection lost while enqueueing — reconnect before sending")
            # Safety-net wait for the producer to actually send this frame. Worst case the
            # frame sits behind a full queue, and the producer sleeps tx_message_wait + up to
            # tx_jitter (plus a drain) between sends — so scale the bound by the full queue
            # depth, not a flat constant. The old `qsize() + 1`, sampled *after* put() returned,
            # could undershoot to ~1 s and fail a legitimately backlogged-but-healthy producer;
            # a flat 5 s would do the same once the queue filled (20 × ~0.35 s ≈ 7 s). The 1.5×
            # headroom covers per-frame drain and scheduling. Only fires if the producer is stuck.
            frame_sent_timeout = max(
                _FRAME_SENT_MIN_TIMEOUT,
                self.tx_queue.maxsize * (self.tx_message_wait + self.tx_jitter) * 1.5,
            )
            try:
                await asyncio.wait_for(frame_sent, timeout=frame_sent_timeout)
            except ConnectionLost:
                # Teardown failed this frame's future — propagate the typed signal.
                _discard(response_future)
                raise
            except TimeoutError as exc:
                # Drain is bounded (#356), so reaching this means the producer is
                # wedged somewhere unknown — a genuine bug. Tear down so the
                # system recovers, and keep the honest 'stuck' signal.
                _discard(response_future)
                self._abort_connection(ConnectionLost("producer stuck — tearing down"))
                raise TimeoutError("Producer task is stuck — frame not sent") from exc
            try:
                await asyncio.wait_for(response_future, timeout=timeout)
            except ConnectionLost:
                raise  # a drop mid-await propagates immediately; never a retry
            except TimeoutError:
                tries += 1
                _logger.debug(
                    f"Timeout awaiting {expected_response} (future: {response_future}), "
                    f"attempting retry {tries} of {retries}"
                )
                if tries <= retries:
                    # Count the consumed retry (#284), but only where a response was genuinely
                    # expected — absent-device detect probes pass warn_timeout=False and their
                    # expected timeouts shouldn't pollute the per-device retry noise floor.
                    if warn_timeout:
                        self.plant.record_retry(request.device_address)
                    if retry_delay > 0:
                        # Discard the orphaned future so a late response from this attempt
                        # doesn't accidentally resolve into the next attempt's future.
                        response_future.cancel()
                        await asyncio.sleep(retry_delay)
                continue
            response = response_future.result()
            if tries > 0:
                _logger.debug(f"Received {response} after {tries} tries")
            if response.error:
                _logger.error(f"Received error response, retrying: {response}")
                tries += 1
                # Unlike the timeout path above, no response_future.cancel() is needed here:
                # the future is already resolved (we just called .result()), so cancel() would
                # be a no-op, and the next attempt overwrites expected_responses[hash] anyway.
                if tries <= retries:
                    if warn_timeout:  # count the consumed retry (#284); skip absent-device probes
                        self.plant.record_retry(request.device_address)
                    if retry_delay > 0:
                        await asyncio.sleep(retry_delay)
                continue
            return response

        if warn_timeout:
            _logger.warning(f"Timeout awaiting {expected_response} after {tries} tries at {timeout}s, giving up")
        else:
            _logger.debug(f"Timeout awaiting {expected_response} after {tries} tries at {timeout}s (probe miss)")
        raise TimeoutError()

capture_frames(sink, duration=60.0) async

Tee redacted TX/RX wire frames to sink for duration seconds.

sink is called with the direction ('rx' or 'tx') and the redacted bytes. The library always redacts before invoking the sink so callers can't accidentally see raw hardware identifiers; persistence, formatting and forwarding are the caller's choice.

Redaction is frame-aware: each complete GivEnergy frame is decoded, its serial-bearing fields (envelope serials, C.serial-tagged register values, LAN-config IPs) are zeroed by type, and the frame is re-encoded with a freshly-computed CRC. Frames that cannot be decoded (unknown function codes, malformed/truncated frames) are emitted intact with a log message — they are never dropped or mangled. The sink sees complete frames (one call per complete frame) rather than raw socket chunks.

Runs alongside the normal refresh loop — does not suspend reads or writes, just tees a copy of each frame to sink. Only one capture may run on a Client at a time; calling while one is in flight raises RuntimeError.

Source code in givenergy_modbus/client/client.py
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
async def capture_frames(
    self,
    sink: Callable[[Direction, bytes], None],
    duration: float = 60.0,
) -> None:
    """Tee redacted TX/RX wire frames to *sink* for *duration* seconds.

    *sink* is called with the direction ('rx' or 'tx') and the redacted bytes.
    The library always redacts before invoking the sink so callers can't
    accidentally see raw hardware identifiers; persistence, formatting and
    forwarding are the caller's choice.

    Redaction is frame-aware: each complete GivEnergy frame is decoded, its
    serial-bearing fields (envelope serials, C.serial-tagged register values,
    LAN-config IPs) are zeroed by type, and the frame is re-encoded with a
    freshly-computed CRC. Frames that cannot be decoded (unknown function codes,
    malformed/truncated frames) are emitted intact with a log message — they are
    never dropped or mangled. The sink sees complete frames (one call per
    complete frame) rather than raw socket chunks.

    Runs alongside the normal refresh loop — does not suspend reads or writes,
    just tees a copy of each frame to *sink*. Only one capture may run on a
    Client at a time; calling while one is in flight raises RuntimeError.
    """
    if self._capture_sink is not None:
        raise RuntimeError("a frame capture is already running on this client")
    self._capture_sink = sink
    self._capture_redactor_rx = FrameRedactor("rx")
    self._capture_redactor_tx = FrameRedactor("tx")
    try:
        await asyncio.sleep(duration)
    finally:
        # Flush each direction's held tail so the final bytes aren't lost.
        for direction, redactor in (("rx", self._capture_redactor_rx), ("tx", self._capture_redactor_tx)):
            if redactor is not None:
                self._emit_to_sink(direction, redactor.flush())  # type: ignore[arg-type]
        self._capture_sink = None
        self._capture_redactor_rx = None
        self._capture_redactor_tx = None

close() async

Disconnect from the remote host and clean up tasks and queues.

Source code in givenergy_modbus/client/client.py
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
async def close(self):
    """Disconnect from the remote host and clean up tasks and queues."""
    self.connected = False
    self._shutting_down = True
    if self.tx_queue:
        while not self.tx_queue.empty():
            _, frame_sent, _ = self.tx_queue.get_nowait()
            if frame_sent:
                frame_sent.cancel()
    if self.network_producer_task:
        self.network_producer_task.cancel()
    if hasattr(self, "writer") and self.writer:
        self.writer.close()
        try:
            await self.writer.wait_closed()
        except ConnectionResetError:
            pass
        del self.writer

    if self.network_consumer_task:
        self.network_consumer_task.cancel()
    if hasattr(self, "reader") and self.reader:
        self.reader.feed_eof()
        self.reader.set_exception(RuntimeError("cancelling"))
        del self.reader

    self.expected_responses = {}

connect() async

Connect to the remote host and start background tasks.

Idempotent: if the client is already connected, the existing connection and background tasks are torn down before establishing a new one. This makes connect() safe to use as a reconnect primitive without a separate close() step, and guarantees the new background tasks see _shutting_down as False even after a prior close().

Source code in givenergy_modbus/client/client.py
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
async def connect(self) -> None:
    """Connect to the remote host and start background tasks.

    Idempotent: if the client is already connected, the existing connection
    and background tasks are torn down before establishing a new one. This
    makes ``connect()`` safe to use as a reconnect primitive without a
    separate ``close()`` step, and guarantees the new background tasks see
    ``_shutting_down`` as False even after a prior ``close()``.
    """
    # After an unexpected EOF the consumer sets ``connected = False`` and exits,
    # but the reader/writer/producer-task can still be live — calling
    # ``connect()`` again without a tear-down would leave the old producer task
    # running against shared state alongside the new one. Treat any of those
    # leftover resources as "needs cleanup", not just the ``connected`` flag.
    if (
        self.connected
        or self.network_consumer_task is not None
        or self.network_producer_task is not None
        or getattr(self, "reader", None) is not None
        or getattr(self, "writer", None) is not None
    ):
        await self.close()
    self._shutting_down = False
    self._connection_lost = False
    try:
        connection = asyncio.open_connection(host=self.host, port=self.port, flags=socket.TCP_NODELAY)
        self.reader, self.writer = await asyncio.wait_for(connection, timeout=self.connect_timeout)
    except OSError as e:
        raise CommunicationError(f"Error connecting to {self.host}:{self.port}") from e
    self.network_consumer_task = asyncio.create_task(self._task_network_consumer(), name="network_consumer")
    self.network_producer_task = asyncio.create_task(self._task_network_producer(), name="network_producer")
    # asyncio.create_task(self._task_dump_queues_to_files(), name='dump_queues_to_files'),
    self.connected = True
    _logger.info(f"Connection established to {self.host}:{self.port}")

detect(timeout=2.0, retries=3, probe_timeout=0.5, probe_retries=1, prior=None) async

Discover device type and peripheral topology.

Reads HR(0) and HR(21) from the inverter to resolve the model, then probes for BCUs (HV systems), meters, and LV battery devices.

Both returns the PlantCapabilities instance and assigns it to self.plant.capabilities — the returned object and the one stored on the plant are the same. Subsequent calls to Client.refresh() and Client.load_config() will use it automatically.

When prior is supplied, the probe sweep restricts itself to the addresses listed in it — empty addresses from a cold sweep are skipped. If reality doesn't match prior (device_type changed, or any hinted address fails to confirm), raises PlantTopologyMismatch and leaves self.plant.capabilities as None. The exception carries prior and actual so callers can decide whether to retry, fall back to a cold detect(), or surface the change to the user.

Uses a two-tier timeout: timeout/retries for the known inverter device (where a response is expected), and probe_timeout/probe_retries for speculative probes where absence is the common case.

On a connection-level failure (TimeoutError / CommunicationError) the connection is torn down via close(), so connect()+detect() is atomic: connected flips to False and the standard "reconnect if not connected" idiom recovers (#274). A PlantTopologyMismatch is raised on a healthy connection (only the hint was wrong) and leaves it up so the caller can retry a cold detect().

Source code in givenergy_modbus/client/client.py
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
async def detect(
    self,
    timeout: float = 2.0,
    retries: int = 3,
    probe_timeout: float = 0.5,
    probe_retries: int = 1,
    prior: PlantCapabilities | None = None,
) -> PlantCapabilities:
    """Discover device type and peripheral topology.

    Reads HR(0) and HR(21) from the inverter to resolve the model, then
    probes for BCUs (HV systems), meters, and LV battery devices.

    Both returns the PlantCapabilities instance and assigns it to
    `self.plant.capabilities` — the returned object and the one stored on
    the plant are the same. Subsequent calls to Client.refresh() and
    Client.load_config() will use it automatically.

    When `prior` is supplied, the probe sweep restricts itself to the
    addresses listed in it — empty addresses from a cold sweep are skipped.
    If reality doesn't match prior (device_type changed, or any hinted
    address fails to confirm), raises PlantTopologyMismatch and leaves
    `self.plant.capabilities` as None. The exception carries `prior` and
    `actual` so callers can decide whether to retry, fall back to a cold
    detect(), or surface the change to the user.

    Uses a two-tier timeout: `timeout`/`retries` for the known inverter device
    (where a response is expected), and `probe_timeout`/`probe_retries` for
    speculative probes where absence is the common case.

    On a connection-level failure (TimeoutError / CommunicationError) the
    connection is torn down via close(), so connect()+detect() is atomic:
    `connected` flips to False and the standard "reconnect if not connected"
    idiom recovers (#274). A PlantTopologyMismatch is raised on a healthy
    connection (only the hint was wrong) and leaves it up so the caller can
    retry a cold detect().
    """
    try:
        return await self._detect(
            timeout=timeout,
            retries=retries,
            probe_timeout=probe_timeout,
            probe_retries=probe_retries,
            prior=prior,
        )
    except PlantTopologyMismatch:
        # Healthy connection — only the hint was wrong; capabilities already cleared.
        raise
    except (TimeoutError, CommunicationError):
        # A connection-level failure leaves a half-open socket with capabilities
        # unset. Tear down so connect()+detect() is atomic (#274). Guard close()
        # so a teardown error (e.g. a flaky writer.wait_closed()) can't mask the
        # original failure we're propagating.
        try:
            await self.close()
        except Exception:
            _logger.exception("detect: error during connection teardown after failure")
        raise

execute(requests, timeout, retries, retry_delay=0.5, return_exceptions=False)

Helper to perform multiple requests in bulk.

Source code in givenergy_modbus/client/client.py
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
def execute(
    self,
    requests: list[TransparentRequest],
    timeout: float,
    retries: int,
    retry_delay: float = 0.5,
    return_exceptions: bool = False,
) -> Future[list[TransparentResponse]]:
    """Helper to perform multiple requests in bulk."""
    return asyncio.gather(  # type: ignore[return-value]
        *[
            self.send_request_and_await_response(m, timeout=timeout, retries=retries, retry_delay=retry_delay)
            for m in requests
        ],
        return_exceptions=return_exceptions,
    )

installer_command(requests, timeout=1.5, retries=0, retry_delay=0.5, dry_run=False) async

Execute installer-tier write requests.

Like one_shot_command() but admits registers from INSTALLER_WRITE_REGISTERS. Requests must be constructed with installer=True via the dedicated helpers in client.commands (e.g. set_battery_nominal_power, restore_factory_defaults).

one_shot_command() always rejects installer-flagged requests — the two methods are non-overlapping by design (dual-gate separation).

If dry_run is True, validates but does not transmit.

Source code in givenergy_modbus/client/client.py
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
async def installer_command(
    self,
    requests: list[TransparentRequest],
    timeout: float = 1.5,
    retries: int = 0,
    retry_delay: float = 0.5,
    dry_run: bool = False,
) -> None:
    """Execute installer-tier write requests.

    Like one_shot_command() but admits registers from INSTALLER_WRITE_REGISTERS.
    Requests must be constructed with installer=True via the dedicated helpers in
    client.commands (e.g. set_battery_nominal_power, restore_factory_defaults).

    one_shot_command() always rejects installer-flagged requests — the two methods
    are non-overlapping by design (dual-gate separation).

    If dry_run is True, validates but does not transmit.
    """
    caps = self.plant.capabilities
    model_safe = self._resolve_write_safe()
    installer_safe = model_safe | INSTALLER_WRITE_REGISTERS
    model_label = caps.device_type.name if caps is not None else "undetected"
    for req in requests:
        if isinstance(req, WriteHoldingRegisterRequest):
            effective_safe = installer_safe if req.installer else model_safe
            if req.register not in effective_safe:
                raise InvalidPduState(f"HR({req.register}) is not permitted for {model_label} inverter", req)
        req.ensure_valid_state()
    if not dry_run:
        await self.execute(requests, timeout=timeout, retries=retries, retry_delay=retry_delay)

load_config(timeout=2.0, retries=3, retry_delay=0.5) async

Read HR configuration blocks for the inverter.

Returns the populated plant on full success. On partial/total read failure raises RefreshPartiallySucceeded / RefreshFailed.

Success does not imply fresh: the keep-last-good guards (CRC #255, sub-bus splice #256, bank holds) report a successful poll while serving last-known-good content for a device whose live read was rejected. Display consumers should gate on Plant.register_age() / Plant.block_age(), not on a poll returning.

Source code in givenergy_modbus/client/client.py
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
async def load_config(self, timeout: float = 2.0, retries: int = 3, retry_delay: float = 0.5) -> Plant:
    """Read HR configuration blocks for the inverter.

    Returns the populated plant on full success. On partial/total read
    failure raises ``RefreshPartiallySucceeded`` / ``RefreshFailed``.

    Success does not imply *fresh*: the keep-last-good guards (CRC #255, sub-bus
    splice #256, bank holds) report a successful poll while serving last-known-good
    content for a device whose live read was rejected. Display consumers should gate
    on ``Plant.register_age()`` / ``Plant.block_age()``, not on a poll returning.
    """
    caps = self.plant.capabilities
    if caps is None:
        raise PlantNotDetected(
            "load_config() requires plant capabilities — call detect() once first, "
            "or restore a persisted PlantCapabilities onto client.plant.capabilities."
        )
    inverter = caps.inverter_address
    is_ems = caps.is_ems
    # HR(0,60) is the identity/firmware/serial bank that every device type — including EMS —
    # answers; it's the same bank detect() reads to identify the device. The HR(60,60),
    # HR(120,60) and IR(120,60) banks are inverter-specific; EMS plant controllers don't
    # expose them and the reads time out every poll. The EMS's own window at HR(2040,36)
    # is covered by the EMS-conditional append below. See #86 (wire capture confirmed via
    # dewet22/givenergy-hass#52).
    reqs: list[TransparentRequest] = [
        ReadHoldingRegistersRequest(base_register=0, register_count=60, device_address=inverter),
    ]
    if not is_ems:
        reqs += [
            ReadHoldingRegistersRequest(base_register=60, register_count=60, device_address=inverter),
            ReadHoldingRegistersRequest(base_register=120, register_count=60, device_address=inverter),
            ReadInputRegistersRequest(base_register=120, register_count=60, device_address=inverter),
        ]
    if caps.is_three_phase:
        reqs += [_request_for_range(r, inverter) for r in manifest.LOAD_CONFIG_THREE_PHASE_RANGES]
    if caps.has_extended_slots:
        reqs.append(ReadHoldingRegistersRequest(base_register=240, register_count=60, device_address=inverter))
    # getattr(caps, name), not manifest.gated_ranges(...): the latter reads
    # manifest.CAPABILITIES directly and is blind to PropertyMock-patched
    # instance properties, which two tests rely on for facts with no confirmed
    # model yet (has_hv_cabinet_block/has_peak_shaving_block, #293). Every
    # LOAD_CONFIG_RANGES fact today is firmware-independent, so this is safe;
    # a future firmware-gated fact here would need manifest.gated_ranges(...,
    # caps.arm_firmware_version) instead, since getattr() can't pass arm_fw.
    for name, entries in manifest.LOAD_CONFIG_RANGES.items():
        if getattr(caps, name):
            reqs += [_request_for_range(r, inverter) for r in entries]
    await self._execute_reads(reqs, timeout=timeout, retries=retries, retry_delay=retry_delay)
    return self.plant

one_shot_command(requests, timeout=1.5, retries=0, retry_delay=0.5, dry_run=False) async

Execute write requests, validating each against the detected inverter model.

Raises InvalidPduState for any write to a register not permitted for the detected model. When capabilities are not yet known, falls back to the universally-applicable single-phase register set (conservative).

If dry_run is True, validates but does not transmit — running the same PDU validation (ensure_valid_state) the live encode path runs, so a dry run never passes for a request real execution would reject.

Source code in givenergy_modbus/client/client.py
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
async def one_shot_command(
    self,
    requests: list[TransparentRequest],
    timeout: float = 1.5,
    retries: int = 0,
    retry_delay: float = 0.5,
    dry_run: bool = False,
) -> None:
    """Execute write requests, validating each against the detected inverter model.

    Raises InvalidPduState for any write to a register not permitted for the
    detected model. When capabilities are not yet known, falls back to the
    universally-applicable single-phase register set (conservative).

    If dry_run is True, validates but does not transmit — running the same PDU
    validation (``ensure_valid_state``) the live encode path runs, so a dry run
    never passes for a request real execution would reject.
    """
    caps = self.plant.capabilities
    safe = self._resolve_write_safe()
    model_label = caps.device_type.name if caps is not None else "undetected"
    for req in requests:
        if isinstance(req, WriteHoldingRegisterRequest):
            if req.installer:
                raise InvalidPduState(
                    f"HR({req.register}) is an installer-tier request; use installer_command() instead",
                    req,
                )
            if req.register not in safe:
                raise InvalidPduState(f"HR({req.register}) is not permitted for {model_label} inverter", req)
        # Run the same PDU-level validation encode() runs (value bounds, global
        # safe-register set), so dry-run and live paths reject identically.
        req.ensure_valid_state()
    if not dry_run:
        await self.execute(requests, timeout=timeout, retries=retries, retry_delay=retry_delay)

probe_alive(timeout=2.0, retries=0) async

Cheap reconnect liveness gate: read HR(0) once and report whether the inverter answered.

Distinct from :meth:detect by design. Its ONLY job is "is the inverter responding at all?", so on a hung dongle it fails fast (default retries=0, no peripheral sweep) and its failure is free — the caller just probes again next tick. On the first success the caller runs a full, robust detect(retries=3) to re-establish topology; that detect stays robust because it no longer carries the probe's fail-fast constraint (which is the whole reason this is a separate method rather than detect(retries=0) — one call cannot be both the cheap gate and the robust recovery, since the identity read shares a single retries).

Mirrors :meth:detect's connection discipline, returning a bool instead of raising:

  • Alive (HR(0) came back): the socket is left open, so the caller's follow-up detect reuses the same live connection.
  • Not alive (timeout / :class:CommunicationError, or a response that left no usable HR(0)): the socket is closed (mirroring detect's #274 teardown), releasing it for the dongle's quiet window so the coordinator uniformly reconnects next tick. Never raises.

Reuses the exact HR(0,60)@0x11 read :meth:detect uses (the read proven against real hardware). Reconnect cadence/backoff stays the caller's concern (#356): this owns only the single check.

Source code in givenergy_modbus/client/client.py
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
async def probe_alive(self, timeout: float = 2.0, retries: int = 0) -> bool:
    """Cheap reconnect liveness gate: read HR(0) once and report whether the inverter answered.

    Distinct from :meth:`detect` by design. Its ONLY job is "is the inverter responding at
    all?", so on a hung dongle it fails fast (default ``retries=0``, no peripheral sweep) and
    its failure is free — the caller just probes again next tick. On the first success the
    caller runs a full, robust ``detect(retries=3)`` to re-establish topology; that detect
    stays robust because it no longer carries the probe's fail-fast constraint (which is the
    whole reason this is a separate method rather than ``detect(retries=0)`` — one call cannot
    be both the cheap gate and the robust recovery, since the identity read shares a single
    ``retries``).

    Mirrors :meth:`detect`'s connection discipline, returning a bool instead of raising:

    - **Alive** (HR(0) came back): the socket is left **open**, so the caller's follow-up
      ``detect`` reuses the same live connection.
    - **Not alive** (timeout / :class:`CommunicationError`, or a response that left no usable
      HR(0)): the socket is **closed** (mirroring detect's #274 teardown), releasing it for the
      dongle's quiet window so the coordinator uniformly reconnects next tick. Never raises.

    Reuses the exact HR(0,60)@0x11 read :meth:`detect` uses (the read proven against real
    hardware). Reconnect cadence/backoff stays the caller's concern (#356): this owns only the
    single check.
    """
    # Liveness must come from THIS probe's fresh response, not any cached HR(0): a
    # stale HR(0) from an earlier healthy connection would otherwise make a fresh
    # non-committing probe look alive (e.g. the dongle returns an all-zero bank that
    # _commit_bank rejects while the read still returns — Codex review). The block's
    # ingestion timestamp is stamped ONLY on a successful commit (which applies every
    # guard: Modbus error, CRC, all-zero rejection), so "did this probe re-stamp the
    # HR(0,60) block" is the honest, guard-delegating liveness signal.
    block = (0x11, "HR", 0, 60)
    stamped_before = self.plant.register_block_updated_at.get(block)
    try:
        await self.send_request_and_await_response(
            ReadHoldingRegistersRequest(base_register=0, register_count=60, device_address=0x11),
            timeout=timeout,
            retries=retries,
        )
        stamped_after = self.plant.register_block_updated_at.get(block)
        alive = stamped_after is not None and stamped_after != stamped_before
    except (TimeoutError, CommunicationError):
        alive = False
    if not alive:
        # Release the socket on any not-alive outcome, mirroring detect()'s teardown so a
        # failed liveness check leaves no half-open connection. Guard close() so a teardown
        # error can't turn a clean False into an exception.
        try:
            await self.close()
        except Exception:
            _logger.exception("probe_alive: error during connection teardown after failed liveness probe")
    return alive

refresh(timeout=2.0, retries=1, retry_delay=0.5, ir0_max_age=None, *, max_age=None) async

Read IR measurement blocks for all known devices.

Returns the populated plant on full success. On partial/total read failure raises RefreshPartiallySucceeded / RefreshFailed.

Success does not imply fresh: the keep-last-good guards (CRC #255, sub-bus splice #256, bank holds) report a successful poll while serving last-known-good content for a device whose live read was rejected. Display consumers should gate on Plant.register_age() / Plant.block_age(), not on a poll returning.

The timeout=2.0, retries=1 defaults are tuned for a contended bus: the inverter serialises requests, so when other clients (GivTCP, the vendor app, Predbat) poll the same unit a tighter budget produces spurious timeouts even though the device is responsive (#132). Pass a tighter budget if you own the bus exclusively and want genuine failures surfaced faster.

max_age (seconds) opts in to skip-if-fresh for any IR bank (#196, #207): GivEnergy dongles fan out the responses to whoever is polling them (the cloud, the app, another client), so the consumer often already has recent data in cache without us asking. When set, any IR bank committed within max_age seconds is not re-solicited this cycle. Defaults to None — always solicit, the historic behaviour. Note the fan-out only exists while something else is polling the unit; on a cloud-disconnected dongle the blocks age out and we solicit them. The fan-out is opportunistic, not a promise that concurrent access is free — some dongles idle-reap the connection under multiple clients (hass#95) and reconnect transparently; the skip degrades safely (blocks age out, we solicit).

ir0_max_age is deprecated — use max_age instead. It applied the same logic to IR(0,60) only; max_age extends it to every bank. Will be removed in 3.0.

Source code in givenergy_modbus/client/client.py
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
async def refresh(
    self,
    timeout: float = 2.0,
    retries: int = 1,
    retry_delay: float = 0.5,
    ir0_max_age: float | None = None,
    *,
    max_age: float | None = None,
) -> Plant:
    """Read IR measurement blocks for all known devices.

    Returns the populated plant on full success. On partial/total read
    failure raises ``RefreshPartiallySucceeded`` / ``RefreshFailed``.

    Success does not imply *fresh*: the keep-last-good guards (CRC #255, sub-bus
    splice #256, bank holds) report a successful poll while serving last-known-good
    content for a device whose live read was rejected. Display consumers should gate
    on ``Plant.register_age()`` / ``Plant.block_age()``, not on a poll returning.

    The ``timeout=2.0, retries=1`` defaults are tuned for a contended bus: the
    inverter serialises requests, so when other clients (GivTCP, the vendor app,
    Predbat) poll the same unit a tighter budget produces spurious timeouts even
    though the device is responsive (#132). Pass a tighter budget if you own the
    bus exclusively and want genuine failures surfaced faster.

    ``max_age`` (seconds) opts in to skip-if-fresh for any IR bank (#196, #207):
    GivEnergy dongles fan out the responses to whoever is polling them (the cloud,
    the app, another client), so the consumer often already has recent data in cache
    without us asking. When set, any IR bank committed within ``max_age`` seconds
    is not re-solicited this cycle. Defaults to ``None`` — always solicit, the
    historic behaviour. Note the fan-out only exists while something else is polling
    the unit; on a cloud-disconnected dongle the blocks age out and we solicit them.
    The fan-out is opportunistic, not a promise that concurrent access is free —
    some dongles idle-reap the connection under multiple clients (hass#95) and
    reconnect transparently; the skip degrades safely (blocks age out, we solicit).

    ``ir0_max_age`` is deprecated — use ``max_age`` instead. It applied the same
    logic to IR(0,60) only; ``max_age`` extends it to every bank. Will be removed
    in 3.0.
    """
    caps = self.plant.capabilities
    if caps is None:
        raise PlantNotDetected(
            "refresh() requires plant capabilities — call detect() once first, "
            "or restore a persisted PlantCapabilities onto client.plant.capabilities."
        )
    if ir0_max_age is not None:
        warnings.warn(
            "refresh(ir0_max_age=...) is deprecated; use max_age= instead "
            "(applies to all banks, not just IR(0,60)). ir0_max_age will be "
            "removed in 3.0.",
            DeprecationWarning,
            stacklevel=2,
        )
        if max_age is None:
            max_age = ir0_max_age
    await self._execute_reads(
        _refresh_ranges(caps, max_age, self.plant),
        timeout=timeout,
        retries=retries,
        retry_delay=retry_delay,
    )
    return self.plant

refresh_plant(full_refresh=True, max_batteries=5, timeout=2.0, retries=1, retry_delay=0.5) async

Deprecated orchestrator — run detect() once, then drive your own loop.

.. deprecated:: Will be removed in 3.0 (soon). This composes detect() (when needed) + load_config() + refresh(), which is trivial to do in the consumer where the partial-failure policy belongs. It propagates RefreshPartiallySucceeded / RefreshFailed like the primitives — note that on a full refresh a partial failure in load_config() short-circuits before refresh() runs; call the primitives directly for full control.

Unlike the primitives, this wrapper runs ``detect()`` for you if
capabilities are absent (preserving the legacy connect-then-refresh shape).
New code should call ``detect()`` then ``load_config()`` / ``refresh()``
directly — the primitives raise ``PlantNotDetected`` rather than guessing
an address.
Source code in givenergy_modbus/client/client.py
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
async def refresh_plant(
    self,
    full_refresh: bool = True,
    max_batteries: int = 5,
    timeout: float = 2.0,
    retries: int = 1,
    retry_delay: float = 0.5,
) -> Plant:
    """Deprecated orchestrator — run ``detect()`` once, then drive your own loop.

    .. deprecated::
        Will be removed in 3.0 (soon). This composes ``detect()`` (when needed) +
        ``load_config()`` + ``refresh()``, which is trivial to do in the consumer
        where the partial-failure policy belongs. It propagates
        ``RefreshPartiallySucceeded`` / ``RefreshFailed`` like the primitives —
        note that on a full refresh a partial failure in ``load_config()``
        short-circuits before ``refresh()`` runs; call the primitives directly for
        full control.

        Unlike the primitives, this wrapper runs ``detect()`` for you if
        capabilities are absent (preserving the legacy connect-then-refresh shape).
        New code should call ``detect()`` then ``load_config()`` / ``refresh()``
        directly — the primitives raise ``PlantNotDetected`` rather than guessing
        an address.
    """
    warnings.warn(
        "Client.refresh_plant() is deprecated and will be removed in 3.0. Run detect() once, then "
        "drive your own poll loop over load_config()/refresh(). It now propagates "
        "RefreshPartiallySucceeded/RefreshFailed on partial/total read failure.",
        DeprecationWarning,
        stacklevel=2,
    )
    if max_batteries != 5:
        # Battery addresses now come from detect()/capabilities, so this argument
        # no longer does anything — warn rather than silently ignore a custom value.
        warnings.warn(
            "The max_batteries argument to refresh_plant() is ignored — battery "
            "addresses are now discovered by detect(). It will be removed with "
            "refresh_plant() in 3.0.",
            DeprecationWarning,
            stacklevel=2,
        )
    # The primitives require capabilities; as the legacy one-call wrapper, detect
    # them here if the caller hasn't, so connect()-then-refresh_plant() still works
    # (it now addresses correctly per model — issue #105, where an AIO answering at
    # 0x11 timed out under the old 0x32 fallback).
    if self.plant.capabilities is None:
        self.plant.capabilities = await self.detect(timeout=timeout, retries=retries)
    if full_refresh:
        await self.load_config(timeout=timeout, retries=retries, retry_delay=retry_delay)
    await self.refresh(timeout=timeout, retries=retries, retry_delay=retry_delay)
    return self.plant

send_request_and_await_response(request, timeout, retries, retry_delay=0.5, warn_timeout=True) async

Send a request to the remote, await and return the response.

On timeout, retry_delay seconds pass before the next attempt is enqueued. The default of 0.5s was chosen to overcome the multi-second silent-window failure mode observed in the field — firing the retry immediately tends to land it inside the same silent window as the original request, accomplishing nothing. Callers that want the original "retry immediately" behaviour (e.g. fast probes, latency- sensitive interactive commands) should pass retry_delay=0.

Source code in givenergy_modbus/client/client.py
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
async def send_request_and_await_response(  # noqa: C901
    self,
    request: TransparentRequest,
    timeout: float,
    retries: int,
    retry_delay: float = 0.5,
    warn_timeout: bool = True,
) -> TransparentResponse:
    """Send a request to the remote, await and return the response.

    On timeout, ``retry_delay`` seconds pass before the next attempt is
    enqueued. The default of 0.5s was chosen to overcome the multi-second
    silent-window failure mode observed in the field — firing the retry
    immediately tends to land it inside the same silent window as the
    original request, accomplishing nothing. Callers that want the
    original "retry immediately" behaviour (e.g. fast probes, latency-
    sensitive interactive commands) should pass ``retry_delay=0``.
    """
    if self._connection_lost:
        raise ConnectionLost("connection lost — reconnect before sending")

    # mark the expected response
    expected_response = request.expected_response()
    expected_shape_hash = expected_response.shape_hash()
    existing_response_future = self.expected_responses.get(expected_shape_hash, None)
    if existing_response_future and not existing_response_future.done():
        _logger.debug(f"Cancelling existing in-flight request and replacing: {request}")
        existing_response_future.cancel()

    raw_frame = request.encode()

    def _discard(fut: "Future[TransparentResponse]") -> None:
        # Abandon a future and remove its registration — but only if it's still the one
        # mapped under expected_shape_hash. A newer same-shaped caller may have replaced it
        # (see existing_response_future above); evicting that newer mapping would leave the
        # newer caller unable to receive its response.
        fut.cancel()
        if self.expected_responses.get(expected_shape_hash) is fut:
            del self.expected_responses[expected_shape_hash]

    tries = 0
    while tries <= retries:
        response_future: Future[TransparentResponse] = asyncio.get_running_loop().create_future()
        self.expected_responses[expected_shape_hash] = response_future
        frame_sent = asyncio.get_running_loop().create_future()
        try:
            await asyncio.wait_for(self.tx_queue.put((raw_frame, frame_sent, response_future)), timeout=5.0)
        except TimeoutError as exc:
            _discard(response_future)
            raise TimeoutError("TX queue full — producer task has likely died") from exc
        if self._connection_lost:
            # Lost the race with _abort_connection's queue-drain: our frame was
            # enqueued after the drain and will never be sent. _discard cancels the
            # response future, so a post-reconnect producer skips the stale frame
            # (the queue-front skip-if-resolved check).
            _discard(response_future)
            raise ConnectionLost("connection lost while enqueueing — reconnect before sending")
        # Safety-net wait for the producer to actually send this frame. Worst case the
        # frame sits behind a full queue, and the producer sleeps tx_message_wait + up to
        # tx_jitter (plus a drain) between sends — so scale the bound by the full queue
        # depth, not a flat constant. The old `qsize() + 1`, sampled *after* put() returned,
        # could undershoot to ~1 s and fail a legitimately backlogged-but-healthy producer;
        # a flat 5 s would do the same once the queue filled (20 × ~0.35 s ≈ 7 s). The 1.5×
        # headroom covers per-frame drain and scheduling. Only fires if the producer is stuck.
        frame_sent_timeout = max(
            _FRAME_SENT_MIN_TIMEOUT,
            self.tx_queue.maxsize * (self.tx_message_wait + self.tx_jitter) * 1.5,
        )
        try:
            await asyncio.wait_for(frame_sent, timeout=frame_sent_timeout)
        except ConnectionLost:
            # Teardown failed this frame's future — propagate the typed signal.
            _discard(response_future)
            raise
        except TimeoutError as exc:
            # Drain is bounded (#356), so reaching this means the producer is
            # wedged somewhere unknown — a genuine bug. Tear down so the
            # system recovers, and keep the honest 'stuck' signal.
            _discard(response_future)
            self._abort_connection(ConnectionLost("producer stuck — tearing down"))
            raise TimeoutError("Producer task is stuck — frame not sent") from exc
        try:
            await asyncio.wait_for(response_future, timeout=timeout)
        except ConnectionLost:
            raise  # a drop mid-await propagates immediately; never a retry
        except TimeoutError:
            tries += 1
            _logger.debug(
                f"Timeout awaiting {expected_response} (future: {response_future}), "
                f"attempting retry {tries} of {retries}"
            )
            if tries <= retries:
                # Count the consumed retry (#284), but only where a response was genuinely
                # expected — absent-device detect probes pass warn_timeout=False and their
                # expected timeouts shouldn't pollute the per-device retry noise floor.
                if warn_timeout:
                    self.plant.record_retry(request.device_address)
                if retry_delay > 0:
                    # Discard the orphaned future so a late response from this attempt
                    # doesn't accidentally resolve into the next attempt's future.
                    response_future.cancel()
                    await asyncio.sleep(retry_delay)
            continue
        response = response_future.result()
        if tries > 0:
            _logger.debug(f"Received {response} after {tries} tries")
        if response.error:
            _logger.error(f"Received error response, retrying: {response}")
            tries += 1
            # Unlike the timeout path above, no response_future.cancel() is needed here:
            # the future is already resolved (we just called .result()), so cancel() would
            # be a no-op, and the next attempt overwrites expected_responses[hash] anyway.
            if tries <= retries:
                if warn_timeout:  # count the consumed retry (#284); skip absent-device probes
                    self.plant.record_retry(request.device_address)
                if retry_delay > 0:
                    await asyncio.sleep(retry_delay)
            continue
        return response

    if warn_timeout:
        _logger.warning(f"Timeout awaiting {expected_response} after {tries} tries at {timeout}s, giving up")
    else:
        _logger.debug(f"Timeout awaiting {expected_response} after {tries} tries at {timeout}s (probe miss)")
    raise TimeoutError()

watch_plant(handler=None, refresh_period=15.0, max_batteries=5, timeout=2.0, retries=1, retry_delay=0.5, passive=False) async

Deprecated poll loop — own the loop in the consumer instead.

.. deprecated:: Will be removed in 3.0. Connect, detect(), then loop over load_config() / refresh() yourself, handling RefreshPartiallySucceeded / RefreshFailed as suits the consumer.

Source code in givenergy_modbus/client/client.py
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
async def watch_plant(
    self,
    handler: Callable | None = None,
    refresh_period: float = 15.0,
    max_batteries: int = 5,
    timeout: float = 2.0,
    retries: int = 1,
    retry_delay: float = 0.5,
    passive: bool = False,
):
    """Deprecated poll loop — own the loop in the consumer instead.

    .. deprecated::
        Will be removed in 3.0. Connect, ``detect()``, then loop over
        ``load_config()`` / ``refresh()`` yourself, handling
        ``RefreshPartiallySucceeded`` / ``RefreshFailed`` as suits the consumer.
    """
    warnings.warn(
        "Client.watch_plant() is deprecated and will be removed in 3.0. Own your poll loop: "
        "connect(), detect(), then loop over load_config()/refresh() handling "
        "RefreshPartiallySucceeded/RefreshFailed as you see fit.",
        DeprecationWarning,
        stacklevel=2,
    )
    await self.connect()
    await self.refresh_plant(
        True,
        max_batteries=max_batteries,
        timeout=timeout,
        retries=retries,
        retry_delay=retry_delay,
    )
    while True:
        if handler:
            handler()
        await asyncio.sleep(refresh_period)
        if not passive:
            # Defer to refresh_plant so capability-aware polling (EMS, gateway,
            # three-phase, HV stacks, meters) is included on each tick rather
            # than the legacy single-phase IR(0)/IR(180) + battery shape.
            await self.refresh_plant(
                full_refresh=False,
                max_batteries=max_batteries,
                timeout=timeout,
                retries=retries,
                retry_delay=retry_delay,
            )

FrameRedactor

Frame-aware stateful redactor for a captured GivEnergy byte stream.

Replaces StreamRedactor: instead of running byte-level regex over raw socket chunks, it reassembles complete GivEnergy frames (using the same 0x5959 marker scan the Framer uses), decodes each one, redacts only the known-sensitive fields by type (envelope serials, C.serial-tagged register values, LAN-config IPs), and re-encodes with a freshly-computed CRC.

Any bytes that cannot be decoded — InvalidFrame results, inter-frame garbage, or a partial frame held at stream end — are emitted intact (not mangled) with a log message. Nothing on the wire is ever dropped: the capture is always complete.

Not thread-safe; use one instance per capture direction.

See #158 B-3 for the design rationale and the LanConfigBroadcast PDU that handles the #100 WO-dongle LAN-config broadcasts.

Source code in givenergy_modbus/client/client.py
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
class FrameRedactor:
    """Frame-aware stateful redactor for a captured GivEnergy byte stream.

    Replaces ``StreamRedactor``: instead of running byte-level regex over raw socket
    chunks, it reassembles complete GivEnergy frames (using the same 0x5959 marker
    scan the ``Framer`` uses), decodes each one, redacts only the known-sensitive
    fields by type (envelope serials, C.serial-tagged register values, LAN-config IPs),
    and re-encodes with a freshly-computed CRC.

    Any bytes that cannot be decoded — ``InvalidFrame`` results, inter-frame garbage,
    or a partial frame held at stream end — are emitted **intact** (not mangled) with
    a log message.  Nothing on the wire is ever dropped: the capture is always complete.

    Not thread-safe; use one instance per capture direction.

    See #158 B-3 for the design rationale and the ``LanConfigBroadcast`` PDU that
    handles the #100 WO-dongle LAN-config broadcasts.
    """

    def __init__(self, direction: "Direction" = "rx") -> None:
        self._buf = b""
        self._direction = direction  # determines which PDU decoder to use

    def feed(self, chunk: bytes) -> bytes:
        """Absorb raw bytes; return redacted output for any complete frames found."""
        self._buf += chunk
        return self._process()

    def flush(self) -> bytes:
        """Emit any remaining buffered bytes intact and reset. Call at stream end."""
        tail = self._buf
        self._buf = b""
        if tail:
            _logger.debug("FrameRedactor flushing %db of incomplete/trailing bytes intact", len(tail))
        return tail

    def _process(self) -> bytes:
        out = b""
        while self._buf:
            marker_pos = self._buf.find(_FRAME_MARKER)
            if marker_pos < 0:
                # No frame marker in buffer — keep the last 3 bytes (a split marker
                # could arrive next chunk) and emit the rest intact.
                keep = len(_FRAME_MARKER) - 1
                if len(self._buf) > keep:
                    garbage, self._buf = self._buf[:-keep], self._buf[-keep:]
                    _logger.debug("FrameRedactor: %db pre-marker garbage emitted intact", len(garbage))
                    out += garbage
                break
            if marker_pos > 0:
                # Garbage before the marker — emit intact.
                garbage, self._buf = self._buf[:marker_pos], self._buf[marker_pos:]
                _logger.debug("FrameRedactor: %db inter-frame garbage emitted intact", len(garbage))
                out += garbage
                continue
            # Marker is at position 0. Read the length field to know frame size.
            if len(self._buf) < 6:
                break  # not enough bytes for the MBAP length field yet
            hdr_len = int.from_bytes(self._buf[4:6], "big")
            if hdr_len > 300:
                # A real frame's MBAP length never exceeds ~300 (60-register cap). A larger
                # value means this marker is a false positive (random bytes that happen to
                # match) — emit it intact as garbage and resume scanning, rather than buffering
                # up to ~64 KB for a frame that will never complete. Mirrors framer.py's guard.
                skip = len(_FRAME_MARKER)
                garbage, self._buf = self._buf[:skip], self._buf[skip:]
                _logger.debug("FrameRedactor: false marker (len=0x%04x), %db emitted intact", hdr_len, len(garbage))
                out += garbage
                continue
            frame_len = 6 + hdr_len
            if len(self._buf) < frame_len:
                break  # partial frame — wait for more data
            frame, self._buf = self._buf[:frame_len], self._buf[frame_len:]
            out += self._redact_frame(frame)
        return out

    def _redact_frame(self, frame: bytes) -> bytes:
        from givenergy_modbus.model.register import Converter
        from givenergy_modbus.model.register_cache import _get_serial_groups
        from givenergy_modbus.pdu import ClientIncomingMessage, ClientOutgoingMessage
        from givenergy_modbus.pdu.lan_config import LanConfigBroadcast
        from givenergy_modbus.pdu.read_registers import ReadRegistersResponse

        # TX frames are ClientOutgoingMessage (requests); RX frames are
        # ClientIncomingMessage (responses/heartbeats).  Using the wrong decoder
        # silently falls through to intact-passthrough, leaking the adapter serial
        # in every captured request.  Pass the right decoder by direction.
        decoder_class = ClientOutgoingMessage if self._direction == "tx" else ClientIncomingMessage
        try:
            pdu = decoder_class.decode_bytes(frame)
        except Exception:
            _logger.warning("FrameRedactor: undecodable frame (%db) emitted intact", len(frame))
            return frame

        # LanConfigBroadcast: delegate to its own redact() — handles serial + IPs
        if isinstance(pdu, LanConfigBroadcast):
            return pdu.redact().encode()

        # Redact envelope serials (present on all Transparent PDUs). FAIL-CLOSED
        # (redact_serial_strict): an envelope value that doesn't parse as a GE serial is
        # blanked, not passed through. The Gateway's write-response envelopes carry a
        # NUL-interrupted serial variant that fails the pattern, and the fail-open
        # redact_serial leaked its real unit digits verbatim (first write-path capture,
        # 2026-07-09). Unlike the register-payload path below — which must fail open
        # because serial groups overlap ordinary data — the envelope is unambiguous,
        # so it takes the same strict treatment Plant.redact() applies to these exact
        # fields (#212/#214).
        if hasattr(pdu, "data_adapter_serial_number"):
            pdu.data_adapter_serial_number = Converter.redact_serial_strict(pdu.data_adapter_serial_number)
        if hasattr(pdu, "inverter_serial_number"):
            pdu.inverter_serial_number = Converter.redact_serial_strict(pdu.inverter_serial_number)

        # Redact payload serials in register responses.
        # A serial is stored across 5 consecutive registers; decode the group as a
        # string, apply redact_serial, and re-encode back into register values.
        if isinstance(pdu, ReadRegistersResponse) and not pdu.error:
            reg_type = "HR" if pdu.transparent_function_code == 3 else "IR"
            win_base = pdu.base_register
            win_end = win_base + len(pdu.register_values)  # safer than register_count
            for g_type, g_base, g_count in _get_serial_groups():
                if g_type != reg_type:
                    continue
                g_end = g_base + g_count
                if g_base < win_base or g_end > win_end:
                    continue  # group not fully within this response window
                offset = g_base - win_base
                raw_bytes = b"".join(v.to_bytes(2, "big") for v in pdu.register_values[offset : offset + g_count])
                serial_str = raw_bytes.decode("latin1").replace("\x00", "").upper()
                redacted = Converter.redact_serial(serial_str)
                if redacted is None or redacted == serial_str:
                    # Not a recognised serial — leave unchanged. Mirrors
                    # RegisterCache.redact_serials: rewriting a passthrough value would
                    # zero-pad and corrupt an overlapping group (e.g. the battery serial
                    # group IR(110-114) reading register 114 of a BMU serial at 114-118).
                    continue
                # Re-encode: right-pad to g_count*2 bytes, split back into registers
                redacted_bytes = redacted.encode("latin1").ljust(g_count * 2, b"\x00")[: g_count * 2]
                for i in range(g_count):
                    pdu.register_values[offset + i] = int.from_bytes(redacted_bytes[i * 2 : i * 2 + 2], "big")

        return pdu.encode()

feed(chunk)

Absorb raw bytes; return redacted output for any complete frames found.

Source code in givenergy_modbus/client/client.py
114
115
116
117
def feed(self, chunk: bytes) -> bytes:
    """Absorb raw bytes; return redacted output for any complete frames found."""
    self._buf += chunk
    return self._process()

flush()

Emit any remaining buffered bytes intact and reset. Call at stream end.

Source code in givenergy_modbus/client/client.py
119
120
121
122
123
124
125
def flush(self) -> bytes:
    """Emit any remaining buffered bytes intact and reset. Call at stream end."""
    tail = self._buf
    self._buf = b""
    if tail:
        _logger.debug("FrameRedactor flushing %db of incomplete/trailing bytes intact", len(tail))
    return tail

ProbeRange dataclass

A single Modbus read to issue during detect, with its timeout tier.

tier="known" → full timeout/retries; tier="probe" → fast probe_timeout/probe_retries and retry_delay=0.

Source code in givenergy_modbus/client/client.py
234
235
236
237
238
239
240
241
242
243
244
245
246
@dataclass(frozen=True)
class ProbeRange:
    """A single Modbus read to issue during detect, with its timeout tier.

    ``tier="known"`` → full ``timeout``/``retries``; ``tier="probe"`` → fast
    ``probe_timeout``/``probe_retries`` and ``retry_delay=0``.
    """

    reg_type: str  # "HR" or "IR"
    device_address: int
    base_register: int
    register_count: int
    tier: str  # "known" | "probe"

Commands

High-level methods for interacting with a remote system.

RegisterMap

Mapping of holding register function to location.

Source code in givenergy_modbus/client/commands.py
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
class RegisterMap:
    """Mapping of holding register function to location."""

    ENABLE_CHARGE_TARGET = 20
    BATTERY_POWER_MODE = 27
    SOC_FORCE_ADJUST = 29
    CHARGE_SLOT_2_START = 31
    CHARGE_SLOT_2_END = 32
    SYSTEM_TIME_YEAR = 35
    SYSTEM_TIME_MONTH = 36
    SYSTEM_TIME_DAY = 37
    SYSTEM_TIME_HOUR = 38
    SYSTEM_TIME_MINUTE = 39
    SYSTEM_TIME_SECOND = 40
    DISCHARGE_SLOT_2_START = 44
    DISCHARGE_SLOT_2_END = 45
    ACTIVE_POWER_RATE = 50
    DISCHARGE_SLOT_1_START = 56
    DISCHARGE_SLOT_1_END = 57
    ENABLE_DISCHARGE = 59
    CHARGE_SLOT_1_START = 94
    CHARGE_SLOT_1_END = 95
    ENABLE_CHARGE = 96
    BATTERY_SOC_RESERVE = 110
    BATTERY_CHARGE_LIMIT = 111
    BATTERY_DISCHARGE_LIMIT = 112
    BATTERY_DISCHARGE_MIN_POWER_RESERVE = 114
    CHARGE_TARGET_SOC = 116
    REBOOT = 163
    ENABLE_RTC = 166
    CHARGE_SLOT_3_START = 246
    CHARGE_SLOT_3_END = 247
    CHARGE_SLOT_4_START = 249
    CHARGE_SLOT_4_END = 250
    CHARGE_SLOT_5_START = 252
    CHARGE_SLOT_5_END = 253
    CHARGE_SLOT_6_START = 255
    CHARGE_SLOT_6_END = 256
    CHARGE_SLOT_7_START = 258
    CHARGE_SLOT_7_END = 259
    CHARGE_SLOT_8_START = 261
    CHARGE_SLOT_8_END = 262
    CHARGE_SLOT_9_START = 264
    CHARGE_SLOT_9_END = 265
    CHARGE_SLOT_10_START = 267
    CHARGE_SLOT_10_END = 268
    DISCHARGE_SLOT_3_START = 276
    DISCHARGE_SLOT_3_END = 277
    DISCHARGE_SLOT_4_START = 279
    DISCHARGE_SLOT_4_END = 280
    DISCHARGE_SLOT_5_START = 282
    DISCHARGE_SLOT_5_END = 283
    DISCHARGE_SLOT_6_START = 285
    DISCHARGE_SLOT_6_END = 286
    DISCHARGE_SLOT_7_START = 288
    DISCHARGE_SLOT_7_END = 289
    DISCHARGE_SLOT_8_START = 291
    DISCHARGE_SLOT_8_END = 292
    DISCHARGE_SLOT_9_START = 294
    DISCHARGE_SLOT_9_END = 295
    DISCHARGE_SLOT_10_START = 297
    DISCHARGE_SLOT_10_END = 298
    EXPORT_PRIORITY = 311
    BATTERY_CHARGE_LIMIT_AC = 313
    BATTERY_DISCHARGE_LIMIT_AC = 314
    ENABLE_EPS = 317
    BATTERY_PAUSE_MODE = 318
    BATTERY_PAUSE_SLOT_START = 319
    BATTERY_PAUSE_SLOT_END = 320
    SMART_LOAD_SLOT_1_START = 554  # HR 554-573: 10 start/end pairs (slot N start = 554 + (N-1)*2)
    BATTERY_RESERVE_SOC = 1078  # three-phase only; no single-phase equivalent
    BATTERY_SOC_RESERVE_3PH = 1109  # three-phase shadow of BATTERY_SOC_RESERVE (110)
    CHARGE_TARGET_SOC_3PH = 1111  # three-phase shadow of CHARGE_TARGET_SOC (116)
    AC_CHARGE_ENABLE = 1112
    FORCE_DISCHARGE_ENABLE = 1122
    FORCE_CHARGE_ENABLE = 1123
    EMS_PLANT_ENABLE = 2040
    # EMS plant-level scheduling (HR 2044-2071). Slot start/end pairs live in
    # model/slot_map.EMS_SLOTS; the per-slot SoC targets and export limit are
    # scalar writes defined here. See model/ems.py for the read-side decode.
    EMS_DISCHARGE_TARGET_SOC_1 = 2046
    EMS_DISCHARGE_TARGET_SOC_2 = 2049
    EMS_DISCHARGE_TARGET_SOC_3 = 2052
    EMS_CHARGE_TARGET_SOC_1 = 2055
    EMS_CHARGE_TARGET_SOC_2 = 2058
    EMS_CHARGE_TARGET_SOC_3 = 2061
    EXPORT_SLOT_1_START = 2062
    EXPORT_SLOT_1_END = 2063
    EMS_EXPORT_TARGET_SOC_1 = 2064
    EXPORT_SLOT_2_START = 2065
    EXPORT_SLOT_2_END = 2066
    EMS_EXPORT_TARGET_SOC_2 = 2067
    EXPORT_SLOT_3_START = 2068
    EXPORT_SLOT_3_END = 2069
    EMS_EXPORT_TARGET_SOC_3 = 2070
    EMS_EXPORT_POWER_LIMIT = 2071
    # --- Installer-tier registers (accessible only via Client.installer_command()) ---
    # Grid protection — all require confirm=True (G98/G99/G100 compliance impact)
    V_AC_LOW_LIMIT_TRIP = 63
    V_AC_HIGH_LIMIT_TRIP = 64
    F_AC_LOW_LIMIT_TRIP = 65
    F_AC_HIGH_LIMIT_TRIP = 66
    T_AC_LOW_VOLTAGE_TRIP = 67
    T_AC_HIGH_VOLTAGE_TRIP = 68
    T_AC_LOW_FREQ_TRIP = 69
    T_AC_HIGH_FREQ_TRIP = 70
    V_AC_LOW_LIMIT_RECONNECT = 71
    V_AC_HIGH_LIMIT_RECONNECT = 72
    F_AC_LOW_LIMIT_RECONNECT = 73
    F_AC_HIGH_LIMIT_RECONNECT = 74
    T_AC_LOW_VOLTAGE_RECONNECT = 75
    T_AC_HIGH_VOLTAGE_RECONNECT = 76
    T_AC_LOW_FREQ_RECONNECT = 77
    T_AC_HIGH_FREQ_RECONNECT = 78
    V_AC_LOW_LIMIT_GRID = 79
    V_AC_HIGH_LIMIT_GRID = 80
    F_AC_LOW_LIMIT_GRID = 81
    F_AC_HIGH_LIMIT_GRID = 82
    V_AC_10MIN_PROTECT = 83
    ANTI_ISLANDING_DETECTION = 115
    RESET_ENERGY_TOTALS = 162
    GRID_IMPORT_LIMIT = 101
    GRID_IMPORT_LIMIT_ENABLED = 102
    BATTERY_NOMINAL_POWER = 308
    BATTERY_NOMINAL_CURRENT = 309
    BATTERY_MAX_CHARGE_PCT = 310
    ENABLE_PLANT_MODE = 300
    ENABLE_MICRO_GRID = 332
    ENABLE_EV_CHARGER = 333
    EV_CHARGER_SOC_LIMIT = 336
    ENABLE_GENERATOR = 343
    GENERATOR_START_SOC = 344
    GENERATOR_STOP_SOC = 345
    ENABLE_SMART_LOAD = 540
    SMART_LOAD_CONTROL_SOC = 541
    GENERAL_LOAD_CONTROL_SOC = 543
    GENERATOR_CONTROL_SOC = 544
    EXPORT_POWER_RATE = 1063
    ENABLE_EXPORT_LIMIT_3PH = 1103
    ENABLE_IMPORT_LIMIT_3PH = 1131
    PEAK_SHAVING_EXPORT_LIMIT_ENABLED = 20000
    PEAK_SHAVING_EXPORT_LIMIT = 20001
    PEAK_SHAVING_ENABLED = 20002
    PEAK_SHAVING_THRESHOLD = 20003
    THREE_PHASE_FACTORY_RESET = 1016
    ENABLE_BLACK_START = 5003
    RESTORE_FACTORY_DEFAULTS = 5004

disable_charge()

Prevent the battery from charging at all.

Source code in givenergy_modbus/client/commands.py
291
292
293
294
@deprecated("use set_enable_charge(False) instead")
def disable_charge() -> list[TransparentRequest]:
    """Prevent the battery from charging at all."""
    return set_enable_charge(False)

disable_charge_target()

Removes SOC limit and target 100% charging.

Source code in givenergy_modbus/client/commands.py
224
225
226
227
228
229
def disable_charge_target() -> list[TransparentRequest]:
    """Removes SOC limit and target 100% charging."""
    return [
        WriteHoldingRegisterRequest(RegisterMap.ENABLE_CHARGE_TARGET, 0),
        WriteHoldingRegisterRequest(RegisterMap.CHARGE_TARGET_SOC, 100),
    ]

disable_charge_target_3ph()

Remove SOC limit and target 100% charging on three-phase inverters (HR 1111, shadows HR 116).

Source code in givenergy_modbus/client/commands.py
351
352
353
354
355
356
def disable_charge_target_3ph() -> list[TransparentRequest]:
    """Remove SOC limit and target 100% charging on three-phase inverters (HR 1111, shadows HR 116)."""
    return [
        WriteHoldingRegisterRequest(RegisterMap.ENABLE_CHARGE_TARGET, 0),
        WriteHoldingRegisterRequest(RegisterMap.CHARGE_TARGET_SOC_3PH, 100),
    ]

disable_discharge()

Prevent the battery from discharging at all.

Source code in givenergy_modbus/client/commands.py
303
304
305
306
@deprecated("use set_enable_discharge(False) instead")
def disable_discharge() -> list[TransparentRequest]:
    """Prevent the battery from discharging at all."""
    return set_enable_discharge(False)

enable_black_start(*, confirm=False)

Enable EPS black-start mode (HR5003). Use with care.

Activates black-start capability on EPS-capable inverters. Incorrect use can cause the inverter to energise an island without grid synchronisation. Pass confirm=True to proceed.

Source code in givenergy_modbus/client/commands.py
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
def enable_black_start(*, confirm: bool = False) -> list[WriteHoldingRegisterRequest]:
    """Enable EPS black-start mode (HR5003). Use with care.

    Activates black-start capability on EPS-capable inverters. Incorrect use can
    cause the inverter to energise an island without grid synchronisation.
    Pass confirm=True to proceed.
    """
    if not confirm:
        raise ValueError("enable_black_start() requires confirm=True")
    return [WriteHoldingRegisterRequest(RegisterMap.ENABLE_BLACK_START, 1, installer=True)]

enable_charge()

Enable the battery to charge, depending on the mode and slots set.

Source code in givenergy_modbus/client/commands.py
285
286
287
288
@deprecated("use set_enable_charge(True) instead")
def enable_charge() -> list[TransparentRequest]:
    """Enable the battery to charge, depending on the mode and slots set."""
    return set_enable_charge(True)

enable_discharge()

Enable the battery to discharge, depending on the mode and slots set.

Source code in givenergy_modbus/client/commands.py
297
298
299
300
@deprecated("use set_enable_discharge(True) instead")
def enable_discharge() -> list[TransparentRequest]:
    """Enable the battery to discharge, depending on the mode and slots set."""
    return set_enable_discharge(True)

refresh_plant_data(complete, number_batteries=1, max_batteries=5)

This helper hardcoded device_address=0x32 for every read, which silently failed on models answering elsewhere (e.g. an All-in-One at 0x11 — issue #105). Capability-aware polling — Client.detect() then Client.load_config() / Client.refresh() — replaces it and addresses each device correctly.

Kept as an import-compatible stub so existing imports don't break with an ImportError; it raises PlantNotDetected to signpost the migration rather than rebuild the unsafe blind poll.

Source code in givenergy_modbus/client/commands.py
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
@deprecated("Call Client.detect() then load_config()/refresh() instead — see PlantNotDetected.")
def refresh_plant_data(complete: bool, number_batteries: int = 1, max_batteries: int = 5) -> list[TransparentRequest]:
    """Removed: built a fixed 0x32-addressed poll that timed out on non-0x32 models.

    This helper hardcoded ``device_address=0x32`` for every read, which silently
    failed on models answering elsewhere (e.g. an All-in-One at 0x11 — issue #105).
    Capability-aware polling — ``Client.detect()`` then ``Client.load_config()`` /
    ``Client.refresh()`` — replaces it and addresses each device correctly.

    Kept as an import-compatible stub so existing imports don't break with an
    ``ImportError``; it raises ``PlantNotDetected`` to signpost the migration rather
    than rebuild the unsafe blind poll.
    """
    raise PlantNotDetected(
        "commands.refresh_plant_data() has been removed — it built a fixed 0x32 poll "
        "that timed out on models answering at other addresses (e.g. All-in-One at 0x11). "
        "Call Client.detect() once, then Client.load_config() / Client.refresh()."
    )

reset_charge_slot(idx, slot_map)

Reset charge slot to zero/disabled by index (1-based).

Source code in givenergy_modbus/client/commands.py
779
780
781
def reset_charge_slot(idx: int, slot_map: SlotMap) -> list[TransparentRequest]:
    """Reset charge slot to zero/disabled by index (1-based)."""
    return set_charge_slot_start(idx, None, slot_map) + set_charge_slot_end(idx, None, slot_map)

reset_charge_slot_1(slot_map=SINGLE_PHASE_SLOTS)

Source code in givenergy_modbus/client/commands.py
802
803
804
805
@deprecated("use reset_charge_slot(1, slot_map) instead")
def reset_charge_slot_1(slot_map: SlotMap = SINGLE_PHASE_SLOTS) -> list[TransparentRequest]:
    """Deprecated: use reset_charge_slot(1, slot_map)."""
    return reset_charge_slot(1, slot_map)

reset_charge_slot_2(slot_map=SINGLE_PHASE_SLOTS)

Source code in givenergy_modbus/client/commands.py
814
815
816
817
@deprecated("use reset_charge_slot(2, slot_map) instead")
def reset_charge_slot_2(slot_map: SlotMap = SINGLE_PHASE_SLOTS) -> list[TransparentRequest]:
    """Deprecated: use reset_charge_slot(2, slot_map)."""
    return reset_charge_slot(2, slot_map)

reset_discharge_slot(idx, slot_map)

Reset discharge slot to zero/disabled by index (1-based).

Source code in givenergy_modbus/client/commands.py
791
792
793
def reset_discharge_slot(idx: int, slot_map: SlotMap) -> list[TransparentRequest]:
    """Reset discharge slot to zero/disabled by index (1-based)."""
    return set_discharge_slot_start(idx, None, slot_map) + set_discharge_slot_end(idx, None, slot_map)

reset_discharge_slot_1(slot_map=SINGLE_PHASE_SLOTS)

Source code in givenergy_modbus/client/commands.py
826
827
828
829
@deprecated("use reset_discharge_slot(1, slot_map) instead")
def reset_discharge_slot_1(slot_map: SlotMap = SINGLE_PHASE_SLOTS) -> list[TransparentRequest]:
    """Deprecated: use reset_discharge_slot(1, slot_map)."""
    return reset_discharge_slot(1, slot_map)

reset_discharge_slot_2(slot_map=SINGLE_PHASE_SLOTS)

Source code in givenergy_modbus/client/commands.py
838
839
840
841
@deprecated("use reset_discharge_slot(2, slot_map) instead")
def reset_discharge_slot_2(slot_map: SlotMap = SINGLE_PHASE_SLOTS) -> list[TransparentRequest]:
    """Deprecated: use reset_discharge_slot(2, slot_map)."""
    return reset_discharge_slot(2, slot_map)

reset_energy_totals(*, confirm=False)

Reset all lifetime energy counters (HR162). Irreversible.

This clears the lifetime import/export/charge/discharge totals stored in the inverter. Cannot be undone. Pass confirm=True to proceed.

Source code in givenergy_modbus/client/commands.py
1348
1349
1350
1351
1352
1353
1354
1355
1356
def reset_energy_totals(*, confirm: bool = False) -> list[WriteHoldingRegisterRequest]:
    """Reset all lifetime energy counters (HR162). Irreversible.

    This clears the lifetime import/export/charge/discharge totals stored in
    the inverter. Cannot be undone. Pass confirm=True to proceed.
    """
    if not confirm:
        raise ValueError("reset_energy_totals() is irreversible — pass confirm=True to proceed")
    return [WriteHoldingRegisterRequest(RegisterMap.RESET_ENERGY_TOTALS, 1, installer=True)]

restore_factory_defaults(*, confirm=False)

Restore factory defaults (HR5004). Irreversible — wipes all installer config.

Resets the inverter to factory defaults, including all installer-configured grid-safety limits, battery settings, and operating modes. Pass confirm=True to proceed.

Source code in givenergy_modbus/client/commands.py
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
def restore_factory_defaults(*, confirm: bool = False) -> list[WriteHoldingRegisterRequest]:
    """Restore factory defaults (HR5004). Irreversible — wipes all installer config.

    Resets the inverter to factory defaults, including all installer-configured
    grid-safety limits, battery settings, and operating modes. Pass confirm=True
    to proceed.
    """
    if not confirm:
        raise ValueError("restore_factory_defaults() is irreversible — pass confirm=True to proceed")
    return [WriteHoldingRegisterRequest(RegisterMap.RESTORE_FACTORY_DEFAULTS, 1, installer=True)]

set_ac_charge(enabled)

Enable AC charging on three-phase inverters.

Source code in givenergy_modbus/client/commands.py
580
581
582
def set_ac_charge(enabled: bool) -> list[TransparentRequest]:
    """Enable AC charging on three-phase inverters."""
    return [WriteHoldingRegisterRequest(RegisterMap.AC_CHARGE_ENABLE, 1 if enabled else 0)]

set_active_power_rate(target)

Set the inverter's active power output as a percentage of its rated capacity.

On an EMS-managed inverter this per-inverter write (HR50) is a silent no-op: it is accepted at the modbus layer (no error, unlike the AC-limit registers) but the EMS controller re-asserts its own value, so the change does not stick. There is no EMS-controller active-power-rate command to target instead (the EMS register block has none). See #304.

Source code in givenergy_modbus/client/commands.py
459
460
461
462
463
464
465
466
467
468
469
470
def set_active_power_rate(target: int) -> list[TransparentRequest]:
    """Set the inverter's active power output as a percentage of its rated capacity.

    On an EMS-managed inverter this per-inverter write (HR50) is a silent no-op: it is accepted at
    the modbus layer (no error, unlike the AC-limit registers) but the EMS controller re-asserts its
    own value, so the change does not stick. There is no EMS-controller active-power-rate command to
    target instead (the EMS register block has none). See #304.
    """
    target = _as_int(target, "target")
    if not 0 <= target <= 100:
        raise ValueError(f"Active power rate ({target}) must be in [0-100]%")
    return [WriteHoldingRegisterRequest(RegisterMap.ACTIVE_POWER_RATE, target)]

set_anti_islanding_detection(enabled)

Enable or disable anti-islanding detection (HR115). Installer-tier.

Source code in givenergy_modbus/client/commands.py
940
941
942
def set_anti_islanding_detection(enabled: bool) -> list[WriteHoldingRegisterRequest]:
    """Enable or disable anti-islanding detection (HR115). Installer-tier."""
    return [WriteHoldingRegisterRequest(RegisterMap.ANTI_ISLANDING_DETECTION, 1 if enabled else 0, installer=True)]

set_battery_charge_limit(val)

Set the battery charge rate as a C-rate cap (unit C/100; documented range 0-50 = 0-0.5C).

HR(111) is a battery-side current limit against the pack's capacity C — NOT a percentage of inverter rated power (an earlier docstring said the latter; that was wrong on the unit). The current GE app states it outright: "the maximum charge rate as a percentage of the C rating (unit C/100, range 0-50)", and the installer app names the register BATTERY_MAX_C_RATING. (The older app build we decompiled carried stale metadata — range 1-250, "power percentage" — since corrected by GE; that was the source of our long-standing ambiguity.) So value 50 = 0.5C, and the effective output is min(this battery C-rate, the HR(313/314) inverter-power %, the BMS rating) — the limits sit in series (cells -> inverter -> grid).

Which pair is the live control depends on the battery-to-inverter ratio: on a DC hybrid the small pack (0.5C ~= 2.6 kW on a Gen1) is the tighter constraint, so HR(111/112) is the operative knob (and HR(313/314) is absent on DC hybrids anyway); on an All-in-One the ~6 kW inverter is tighter, so HR(313/314) is the live knob and HR(111/112) only bites when throttled well below inverter capacity. Field-confirmed in #302: sweeping HR(112) 50->100% during a discharge held output flat at ~2.66 kW (= 0.5C for that Gen1 pack, i.e. its 2600 W rating).

We accept 0-100 rather than hard-cap at 50 (bound set in #301/#302): though the app slider caps at 50, the register field tolerates >50 (a live AIO was observed holding HR(112) = 100, set by GivTCP / a max-rate write, not the GE app), so a hard [0-50] would reject a value real devices hold. Values above the pack's real C-rate are accepted but clamped by the battery/inverter — the inverse principle to the AC pair HR(313/314), which keeps a floor of 1 because writing 0 errors on the hardware (#306): cap only what the firmware rejects, document what it merely clamps.

Source code in givenergy_modbus/client/commands.py
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
def set_battery_charge_limit(val: int) -> list[TransparentRequest]:
    """Set the battery charge rate as a C-rate cap (unit C/100; documented range 0-50 = 0-0.5C).

    HR(111) is a *battery-side* current limit against the pack's capacity C — NOT a percentage of
    inverter rated power (an earlier docstring said the latter; that was wrong on the unit). The
    current GE app states it outright: "the maximum charge rate as a percentage of the C rating
    (unit C/100, range 0-50)", and the installer app names the register BATTERY_MAX_C_RATING. (The
    older app build we decompiled carried stale metadata — range 1-250, "power percentage" — since
    corrected by GE; that was the source of our long-standing ambiguity.) So value 50 = 0.5C, and the
    *effective* output is ``min(this battery C-rate, the HR(313/314) inverter-power %, the BMS
    rating)`` — the limits sit in series (cells -> inverter -> grid).

    Which pair is the live control depends on the battery-to-inverter ratio: on a DC hybrid the small
    pack (0.5C ~= 2.6 kW on a Gen1) is the tighter constraint, so HR(111/112) is the operative knob
    (and HR(313/314) is absent on DC hybrids anyway); on an All-in-One the ~6 kW inverter is tighter,
    so HR(313/314) is the live knob and HR(111/112) only bites when throttled well below inverter
    capacity. Field-confirmed in #302: sweeping HR(112) 50->100% during a discharge held output flat
    at ~2.66 kW (= 0.5C for that Gen1 pack, i.e. its 2600 W rating).

    We accept 0-100 rather than hard-cap at 50 (bound set in #301/#302): though the app slider caps
    at 50, the register field tolerates >50 (a live AIO was observed holding HR(112) = 100, set by
    GivTCP / a max-rate write, not the GE app), so a hard [0-50] would reject a value real devices
    hold. Values above the pack's real C-rate are accepted but clamped by the battery/inverter — the
    inverse principle to the AC pair HR(313/314), which keeps a floor of 1 because writing 0 *errors*
    on the hardware (#306): cap only what the firmware rejects, document what it merely clamps.
    """
    val = _as_int(val, "val")
    if not 0 <= val <= 100:
        raise ValueError(f"Specified Charge Limit ({val}) is not in [0-100] (C/100)")
    return [WriteHoldingRegisterRequest(RegisterMap.BATTERY_CHARGE_LIMIT, val)]

set_battery_charge_limit_ac(val)

Set the battery AC charge power limit as a percentage (1-100).

The GE app exposes 0-100 for this control, but writing 0 to HR313/314 does NOT work in practice on (at least) the AC models tested — it ERRORs (hardware-confirmed via a single-phase AC tester: WriteHoldingRegisterResponse(ERROR) then a write timeout). So the floor is 1 and a 0 raises a clean ValueError rather than a doomed write; the 2.5.8 "0 disables" widening trusted the app range and was wrong. The 0-floor is AC-specific — the DC pair :func:set_battery_charge_limit legitimately accepts 0; writing 1 here drives the battery to near-zero.

Source code in givenergy_modbus/client/commands.py
503
504
505
506
507
508
509
510
511
512
513
514
515
516
def set_battery_charge_limit_ac(val: int) -> list[TransparentRequest]:
    """Set the battery AC charge power limit as a percentage (1-100).

    The GE app exposes 0-100 for this control, but writing 0 to HR313/314 does NOT work in practice on
    (at least) the AC models tested — it ERRORs (hardware-confirmed via a single-phase AC tester:
    WriteHoldingRegisterResponse(ERROR) then a write timeout). So the floor is 1 and a 0 raises a clean
    ValueError rather than a doomed write; the 2.5.8 "0 disables" widening trusted the app range and was
    wrong. The 0-floor is AC-specific — the DC pair :func:`set_battery_charge_limit` legitimately
    accepts 0; writing 1 here drives the battery to near-zero.
    """
    val = _as_int(val, "val")
    if not 1 <= val <= 100:
        raise ValueError(f"Specified AC Charge Limit ({val}%) is not in [1-100]%")
    return [WriteHoldingRegisterRequest(RegisterMap.BATTERY_CHARGE_LIMIT_AC, val)]

set_battery_discharge_limit(val)

Set the battery discharge rate as a C-rate cap (unit C/100; meaningful range 0-50 = 0-0.5C).

HR(112) is a battery-side C-rate cap, not an inverter-power percentage — see :func:set_battery_charge_limit for the full unit provenance (current GE app "C/100, range 0-50", installer BATTERY_MAX_C_RATING), the series min(C-rate, inverter %) model, and why this accepts 0-100 rather than the historical [0-50].

Source code in givenergy_modbus/client/commands.py
432
433
434
435
436
437
438
439
440
441
442
443
def set_battery_discharge_limit(val: int) -> list[TransparentRequest]:
    """Set the battery discharge rate as a C-rate cap (unit C/100; meaningful range 0-50 = 0-0.5C).

    HR(112) is a battery-side C-rate cap, not an inverter-power percentage — see
    :func:`set_battery_charge_limit` for the full unit provenance (current GE app "C/100, range
    0-50", installer BATTERY_MAX_C_RATING), the series ``min(C-rate, inverter %)`` model, and why this
    accepts 0-100 rather than the historical [0-50].
    """
    val = _as_int(val, "val")
    if not 0 <= val <= 100:
        raise ValueError(f"Specified Discharge Limit ({val}) is not in [0-100] (C/100)")
    return [WriteHoldingRegisterRequest(RegisterMap.BATTERY_DISCHARGE_LIMIT, val)]

set_battery_discharge_limit_ac(val)

Set the battery AC discharge power limit as a percentage (1-100).

The GE app exposes 0-100 for this control, but writing 0 to HR313/314 does NOT work in practice on (at least) the AC models tested — it ERRORs (hardware-confirmed via a single-phase AC tester: WriteHoldingRegisterResponse(ERROR) then a write timeout). So the floor is 1 and a 0 raises a clean ValueError rather than a doomed write; the 2.5.8 "0 disables" widening trusted the app range and was wrong. The 0-floor is AC-specific — the DC pair :func:set_battery_discharge_limit legitimately accepts 0; writing 1 here drives the battery to near-zero.

Source code in givenergy_modbus/client/commands.py
519
520
521
522
523
524
525
526
527
528
529
530
531
532
def set_battery_discharge_limit_ac(val: int) -> list[TransparentRequest]:
    """Set the battery AC discharge power limit as a percentage (1-100).

    The GE app exposes 0-100 for this control, but writing 0 to HR313/314 does NOT work in practice on
    (at least) the AC models tested — it ERRORs (hardware-confirmed via a single-phase AC tester:
    WriteHoldingRegisterResponse(ERROR) then a write timeout). So the floor is 1 and a 0 raises a clean
    ValueError rather than a doomed write; the 2.5.8 "0 disables" widening trusted the app range and was
    wrong. The 0-floor is AC-specific — the DC pair :func:`set_battery_discharge_limit` legitimately
    accepts 0; writing 1 here drives the battery to near-zero.
    """
    val = _as_int(val, "val")
    if not 1 <= val <= 100:
        raise ValueError(f"Specified AC Discharge Limit ({val}%) is not in [1-100]%")
    return [WriteHoldingRegisterRequest(RegisterMap.BATTERY_DISCHARGE_LIMIT_AC, val)]

set_battery_max_charge_pct(pct)

Set battery maximum charge percentage (HR310). Installer-tier. App range: 20–100.

Source code in givenergy_modbus/client/commands.py
932
933
934
935
936
937
def set_battery_max_charge_pct(pct: int) -> list[WriteHoldingRegisterRequest]:
    """Set battery maximum charge percentage (HR310). Installer-tier. App range: 20–100."""
    val = _as_int(pct, "pct")
    if not (20 <= val <= 100):
        raise ValueError(f"Battery max charge % must be 20–100, got {val}")
    return [WriteHoldingRegisterRequest(RegisterMap.BATTERY_MAX_CHARGE_PCT, val, installer=True)]

set_battery_nominal_current(current)

Set battery nominal current (HR309). Installer-tier.

No explicit app range — accepts any uint16. Consult battery hardware spec.

Source code in givenergy_modbus/client/commands.py
923
924
925
926
927
928
929
def set_battery_nominal_current(current: int) -> list[WriteHoldingRegisterRequest]:
    """Set battery nominal current (HR309). Installer-tier.

    No explicit app range — accepts any uint16. Consult battery hardware spec.
    """
    val = _as_int(current, "current")
    return [WriteHoldingRegisterRequest(RegisterMap.BATTERY_NOMINAL_CURRENT, val, installer=True)]

set_battery_nominal_power(power)

Set battery nominal power (HR308). Installer-tier.

No explicit app range — accepts any uint16. Consult battery hardware spec.

Source code in givenergy_modbus/client/commands.py
915
916
917
918
919
920
def set_battery_nominal_power(power: int) -> list[WriteHoldingRegisterRequest]:
    """Set battery nominal power (HR308). Installer-tier.

    No explicit app range — accepts any uint16. Consult battery hardware spec.
    """
    return [WriteHoldingRegisterRequest(RegisterMap.BATTERY_NOMINAL_POWER, _as_int(power, "power"), installer=True)]

set_battery_pause_mode(val)

Set the battery pause mode.

Source code in givenergy_modbus/client/commands.py
535
536
537
def set_battery_pause_mode(val: BatteryPauseMode) -> list[TransparentRequest]:
    """Set the battery pause mode."""
    return [WriteHoldingRegisterRequest(RegisterMap.BATTERY_PAUSE_MODE, val)]

set_battery_power_reserve(val)

Set the battery power reserve to maintain.

Bounds [4-100]% are unconfirmed against GE firmware docs (gone) but match GivTCP's independent choice for the same register — treat as the working assumption until a portal capture contradicts it.

Source code in givenergy_modbus/client/commands.py
446
447
448
449
450
451
452
453
454
455
456
def set_battery_power_reserve(val: int) -> list[TransparentRequest]:
    """Set the battery power reserve to maintain.

    Bounds [4-100]% are unconfirmed against GE firmware docs (gone) but match
    GivTCP's independent choice for the same register — treat as the working
    assumption until a portal capture contradicts it.
    """
    val = _as_int(val, "val")
    if not 4 <= val <= 100:
        raise ValueError(f"Battery power reserve ({val}) must be in [4-100]%")
    return [WriteHoldingRegisterRequest(RegisterMap.BATTERY_DISCHARGE_MIN_POWER_RESERVE, val)]

set_battery_reserve_soc(val)

Set the battery reserve SOC on three-phase inverters (HR 1078, "Battery Reserve %").

Three-phase only — single-phase units use set_battery_soc_reserve() (HR 110) instead. Bounds [4-100]% are unconfirmed (no GivTCP cross-reference exists for this register); treat as the working assumption until a live three-phase capture confirms them.

Source code in givenergy_modbus/client/commands.py
338
339
340
341
342
343
344
345
346
347
348
def set_battery_reserve_soc(val: int) -> list[TransparentRequest]:
    """Set the battery reserve SOC on three-phase inverters (HR 1078, "Battery Reserve %").

    Three-phase only — single-phase units use set_battery_soc_reserve() (HR 110) instead.
    Bounds [4-100]% are unconfirmed (no GivTCP cross-reference exists for this register);
    treat as the working assumption until a live three-phase capture confirms them.
    """
    val = _as_int(val, "val")
    if not 4 <= val <= 100:
        raise ValueError(f"Battery reserve SOC ({val}) must be in [4-100]%")
    return [WriteHoldingRegisterRequest(RegisterMap.BATTERY_RESERVE_SOC, val)]

set_battery_soc_reserve(val)

Set the minimum level of charge to maintain.

Bounds [4-100]% are unconfirmed against GE firmware docs (gone) but match GivTCP's independent choice for the same register — treat as the working assumption until a portal capture contradicts it.

Source code in givenergy_modbus/client/commands.py
325
326
327
328
329
330
331
332
333
334
335
def set_battery_soc_reserve(val: int) -> list[TransparentRequest]:
    """Set the minimum level of charge to maintain.

    Bounds [4-100]% are unconfirmed against GE firmware docs (gone) but match
    GivTCP's independent choice for the same register — treat as the working
    assumption until a portal capture contradicts it.
    """
    val = _as_int(val, "val")
    if not 4 <= val <= 100:
        raise ValueError(f"Minimum SOC / shallow charge ({val}) must be in [4-100]%")
    return [WriteHoldingRegisterRequest(RegisterMap.BATTERY_SOC_RESERVE, val)]

set_battery_soc_reserve_3ph(val)

Set the minimum SOC reserve on three-phase inverters (HR 1109, shadows single-phase HR 110).

Source code in givenergy_modbus/client/commands.py
359
360
361
362
363
364
def set_battery_soc_reserve_3ph(val: int) -> list[TransparentRequest]:
    """Set the minimum SOC reserve on three-phase inverters (HR 1109, shadows single-phase HR 110)."""
    val = _as_int(val, "val")
    if not 4 <= val <= 100:
        raise ValueError(f"Minimum SOC / shallow charge ({val}) must be in [4-100]%")
    return [WriteHoldingRegisterRequest(RegisterMap.BATTERY_SOC_RESERVE_3PH, val)]

set_calibrate_battery_soc(val=1)

Set the inverter to recalibrate the battery state of charge estimation.

val: 0 = Stop, 1 = Start, 3 = Charge Only

Source code in givenergy_modbus/client/commands.py
275
276
277
278
279
280
281
282
def set_calibrate_battery_soc(val: int = 1) -> list[TransparentRequest]:
    """Set the inverter to recalibrate the battery state of charge estimation.

    val: 0 = Stop, 1 = Start, 3 = Charge Only
    """
    if val not in (0, 1, 3):
        raise ValueError(f"Battery calibration mode ({val}) must be 0 (Stop), 1 (Start) or 3 (Charge Only)")
    return [WriteHoldingRegisterRequest(RegisterMap.SOC_FORCE_ADJUST, val)]

set_charge_slot(idx, timeslot, slot_map)

Set charge slot start & end times by index (1-based).

Source code in givenergy_modbus/client/commands.py
772
773
774
775
776
def set_charge_slot(idx: int, timeslot: TimeSlot, slot_map: SlotMap) -> list[TransparentRequest]:
    """Set charge slot start & end times by index (1-based)."""
    start = timeslot.start if timeslot else None
    end = timeslot.end if timeslot else None
    return set_charge_slot_start(idx, start, slot_map) + set_charge_slot_end(idx, end, slot_map)

set_charge_slot_1(timeslot, slot_map=SINGLE_PHASE_SLOTS)

Source code in givenergy_modbus/client/commands.py
796
797
798
799
@deprecated("use set_charge_slot(1, timeslot, slot_map) instead")
def set_charge_slot_1(timeslot: TimeSlot, slot_map: SlotMap = SINGLE_PHASE_SLOTS) -> list[TransparentRequest]:
    """Deprecated: use set_charge_slot(1, timeslot, slot_map)."""
    return set_charge_slot(1, timeslot, slot_map)

set_charge_slot_2(timeslot, slot_map=SINGLE_PHASE_SLOTS)

Source code in givenergy_modbus/client/commands.py
808
809
810
811
@deprecated("use set_charge_slot(2, timeslot, slot_map) instead")
def set_charge_slot_2(timeslot: TimeSlot, slot_map: SlotMap = SINGLE_PHASE_SLOTS) -> list[TransparentRequest]:
    """Deprecated: use set_charge_slot(2, timeslot, slot_map)."""
    return set_charge_slot(2, timeslot, slot_map)

set_charge_slot_end(idx, t, slot_map)

Set just the end of a charge slot by index (1-based), or clear it if t is None.

Source code in givenergy_modbus/client/commands.py
754
755
756
757
def set_charge_slot_end(idx: int, t: dt_time | None, slot_map: SlotMap) -> list[TransparentRequest]:
    """Set just the end of a charge slot by index (1-based), or clear it if t is None."""
    _, hr_end = _resolve_slot_registers(False, idx, slot_map)
    return _set_slot_endpoint(hr_end, t)

set_charge_slot_start(idx, t, slot_map)

Set just the start of a charge slot by index (1-based), or clear it if t is None.

Source code in givenergy_modbus/client/commands.py
748
749
750
751
def set_charge_slot_start(idx: int, t: dt_time | None, slot_map: SlotMap) -> list[TransparentRequest]:
    """Set just the start of a charge slot by index (1-based), or clear it if t is None."""
    hr_start, _ = _resolve_slot_registers(False, idx, slot_map)
    return _set_slot_endpoint(hr_start, t)

set_charge_target(target_soc)

Source code in givenergy_modbus/client/commands.py
246
247
248
249
@deprecated("use set_charge_target_enabled(target_soc) instead")
def set_charge_target(target_soc: int) -> list[TransparentRequest]:
    """Deprecated: use set_charge_target_enabled(target_soc)."""
    return set_charge_target_enabled(target_soc)

set_charge_target_3ph(target_soc)

Source code in givenergy_modbus/client/commands.py
386
387
388
389
@deprecated("use set_charge_target_enabled_3ph(target_soc) instead")
def set_charge_target_3ph(target_soc: int) -> list[TransparentRequest]:
    """Deprecated: use set_charge_target_enabled_3ph(target_soc)."""
    return set_charge_target_enabled_3ph(target_soc)

set_charge_target_enabled(target_soc)

Enable charging and stop once SOC reaches target_soc. Also referred to as "winter mode".

Source code in givenergy_modbus/client/commands.py
232
233
234
235
236
237
238
239
240
241
242
243
def set_charge_target_enabled(target_soc: int) -> list[TransparentRequest]:
    """Enable charging and stop once SOC reaches target_soc. Also referred to as "winter mode"."""
    target_soc = _as_int(target_soc, "target_soc")
    if not 4 <= target_soc <= 100:
        raise ValueError(f"Charge Target SOC ({target_soc}) must be in [4-100]%")
    ret = set_enable_charge(True)
    if target_soc == 100:
        ret.extend(disable_charge_target())
    else:
        ret.append(WriteHoldingRegisterRequest(RegisterMap.ENABLE_CHARGE_TARGET, 1))
        ret.append(WriteHoldingRegisterRequest(RegisterMap.CHARGE_TARGET_SOC, target_soc))
    return ret

set_charge_target_enabled_3ph(target_soc)

Enable AC charging and set the charge target on three-phase inverters (HR 1111, shadows single-phase HR 116).

Source code in givenergy_modbus/client/commands.py
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
def set_charge_target_enabled_3ph(target_soc: int) -> list[TransparentRequest]:
    """Enable AC charging and set the charge target on three-phase inverters (HR 1111, shadows single-phase HR 116)."""
    target_soc = _as_int(target_soc, "target_soc")
    if not 4 <= target_soc <= 100:
        raise ValueError(f"Charge Target SOC ({target_soc}) must be in [4-100]%")
    ret = set_ac_charge(True)
    if target_soc == 100:
        ret.extend(
            [
                WriteHoldingRegisterRequest(RegisterMap.ENABLE_CHARGE_TARGET, 0),
                WriteHoldingRegisterRequest(RegisterMap.CHARGE_TARGET_SOC_3PH, 100),
            ]
        )
    else:
        ret.append(WriteHoldingRegisterRequest(RegisterMap.ENABLE_CHARGE_TARGET, 1))
        ret.append(WriteHoldingRegisterRequest(RegisterMap.CHARGE_TARGET_SOC_3PH, target_soc))
    return ret

set_charge_target_soc(target_soc)

Set only the charge target SOC (HR 116), leaving the charge / charge-target enable bits untouched.

Source code in givenergy_modbus/client/commands.py
252
253
254
255
256
257
def set_charge_target_soc(target_soc: int) -> list[TransparentRequest]:
    """Set only the charge target SOC (HR 116), leaving the charge / charge-target enable bits untouched."""
    target_soc = _as_int(target_soc, "target_soc")
    if not 4 <= target_soc <= 100:
        raise ValueError(f"Charge Target SOC ({target_soc}) must be in [4-100]%")
    return [WriteHoldingRegisterRequest(RegisterMap.CHARGE_TARGET_SOC, target_soc)]

set_charge_target_soc_3ph(target_soc)

Set only the charge target SOC on three-phase inverters (HR 1111), leaving enable bits untouched.

Source code in givenergy_modbus/client/commands.py
392
393
394
395
396
397
def set_charge_target_soc_3ph(target_soc: int) -> list[TransparentRequest]:
    """Set only the charge target SOC on three-phase inverters (HR 1111), leaving enable bits untouched."""
    target_soc = _as_int(target_soc, "target_soc")
    if not 4 <= target_soc <= 100:
        raise ValueError(f"Charge Target SOC ({target_soc}) must be in [4-100]%")
    return [WriteHoldingRegisterRequest(RegisterMap.CHARGE_TARGET_SOC_3PH, target_soc)]

set_discharge_mode_max_power()

Set the battery discharge mode to maximum power, exporting to the grid if it exceeds load demand.

Source code in givenergy_modbus/client/commands.py
309
310
311
def set_discharge_mode_max_power() -> list[TransparentRequest]:
    """Set the battery discharge mode to maximum power, exporting to the grid if it exceeds load demand."""
    return [WriteHoldingRegisterRequest(RegisterMap.BATTERY_POWER_MODE, 0)]

set_discharge_mode_to_match_demand()

Set the battery discharge mode to match demand, avoiding exporting power to the grid.

Source code in givenergy_modbus/client/commands.py
314
315
316
def set_discharge_mode_to_match_demand() -> list[TransparentRequest]:
    """Set the battery discharge mode to match demand, avoiding exporting power to the grid."""
    return [WriteHoldingRegisterRequest(RegisterMap.BATTERY_POWER_MODE, 1)]

set_discharge_slot(idx, timeslot, slot_map)

Set discharge slot start & end times by index (1-based).

Source code in givenergy_modbus/client/commands.py
784
785
786
787
788
def set_discharge_slot(idx: int, timeslot: TimeSlot, slot_map: SlotMap) -> list[TransparentRequest]:
    """Set discharge slot start & end times by index (1-based)."""
    start = timeslot.start if timeslot else None
    end = timeslot.end if timeslot else None
    return set_discharge_slot_start(idx, start, slot_map) + set_discharge_slot_end(idx, end, slot_map)

set_discharge_slot_1(timeslot, slot_map=SINGLE_PHASE_SLOTS)

Source code in givenergy_modbus/client/commands.py
820
821
822
823
@deprecated("use set_discharge_slot(1, timeslot, slot_map) instead")
def set_discharge_slot_1(timeslot: TimeSlot, slot_map: SlotMap = SINGLE_PHASE_SLOTS) -> list[TransparentRequest]:
    """Deprecated: use set_discharge_slot(1, timeslot, slot_map)."""
    return set_discharge_slot(1, timeslot, slot_map)

set_discharge_slot_2(timeslot, slot_map=SINGLE_PHASE_SLOTS)

Source code in givenergy_modbus/client/commands.py
832
833
834
835
@deprecated("use set_discharge_slot(2, timeslot, slot_map) instead")
def set_discharge_slot_2(timeslot: TimeSlot, slot_map: SlotMap = SINGLE_PHASE_SLOTS) -> list[TransparentRequest]:
    """Deprecated: use set_discharge_slot(2, timeslot, slot_map)."""
    return set_discharge_slot(2, timeslot, slot_map)

set_discharge_slot_end(idx, t, slot_map)

Set just the end of a discharge slot by index (1-based), or clear it if t is None.

Source code in givenergy_modbus/client/commands.py
766
767
768
769
def set_discharge_slot_end(idx: int, t: dt_time | None, slot_map: SlotMap) -> list[TransparentRequest]:
    """Set just the end of a discharge slot by index (1-based), or clear it if t is None."""
    _, hr_end = _resolve_slot_registers(True, idx, slot_map)
    return _set_slot_endpoint(hr_end, t)

set_discharge_slot_start(idx, t, slot_map)

Set just the start of a discharge slot by index (1-based), or clear it if t is None.

Source code in givenergy_modbus/client/commands.py
760
761
762
763
def set_discharge_slot_start(idx: int, t: dt_time | None, slot_map: SlotMap) -> list[TransparentRequest]:
    """Set just the start of a discharge slot by index (1-based), or clear it if t is None."""
    hr_start, _ = _resolve_slot_registers(True, idx, slot_map)
    return _set_slot_endpoint(hr_start, t)

set_ems_charge_slot(idx, timeslot)

Set an EMS plant charge time slot by index (1-3), or clear it if None.

Source code in givenergy_modbus/client/commands.py
635
636
637
638
639
def set_ems_charge_slot(idx: int, timeslot: TimeSlot | None) -> list[TransparentRequest]:
    """Set an EMS plant charge time slot by index (1-3), or clear it if None."""
    if timeslot is None:
        return reset_charge_slot(idx, EMS_SLOTS)
    return set_charge_slot(idx, timeslot, EMS_SLOTS)

set_ems_charge_slot_end(idx, t)

Set just the end of EMS plant charge slot idx (1-3), or clear it if None.

Source code in givenergy_modbus/client/commands.py
647
648
649
def set_ems_charge_slot_end(idx: int, t: dt_time | None) -> list[TransparentRequest]:
    """Set just the end of EMS plant charge slot idx (1-3), or clear it if None."""
    return set_charge_slot_end(idx, t, EMS_SLOTS)

set_ems_charge_slot_start(idx, t)

Set just the start of EMS plant charge slot idx (1-3), or clear it if None.

Source code in givenergy_modbus/client/commands.py
642
643
644
def set_ems_charge_slot_start(idx: int, t: dt_time | None) -> list[TransparentRequest]:
    """Set just the start of EMS plant charge slot idx (1-3), or clear it if None."""
    return set_charge_slot_start(idx, t, EMS_SLOTS)

set_ems_charge_target_soc(idx, target_soc)

Set the SoC target (0-100%) for EMS plant charge slot idx (1-3).

Source code in givenergy_modbus/client/commands.py
669
670
671
672
673
674
675
676
def set_ems_charge_target_soc(idx: int, target_soc: int) -> list[TransparentRequest]:
    """Set the SoC target (0-100%) for EMS plant charge slot idx (1-3)."""
    idx = _as_int(idx, "idx")
    if not 1 <= idx <= 3:
        raise ValueError(f"EMS charge slot index ({idx}) must be in [1-3]")
    return [
        WriteHoldingRegisterRequest(getattr(RegisterMap, f"EMS_CHARGE_TARGET_SOC_{idx}"), _ems_target_soc(target_soc))
    ]

set_ems_discharge_slot(idx, timeslot)

Set an EMS plant discharge time slot by index (1-3), or clear it if None.

Source code in givenergy_modbus/client/commands.py
652
653
654
655
656
def set_ems_discharge_slot(idx: int, timeslot: TimeSlot | None) -> list[TransparentRequest]:
    """Set an EMS plant discharge time slot by index (1-3), or clear it if None."""
    if timeslot is None:
        return reset_discharge_slot(idx, EMS_SLOTS)
    return set_discharge_slot(idx, timeslot, EMS_SLOTS)

set_ems_discharge_slot_end(idx, t)

Set just the end of EMS plant discharge slot idx (1-3), or clear it if None.

Source code in givenergy_modbus/client/commands.py
664
665
666
def set_ems_discharge_slot_end(idx: int, t: dt_time | None) -> list[TransparentRequest]:
    """Set just the end of EMS plant discharge slot idx (1-3), or clear it if None."""
    return set_discharge_slot_end(idx, t, EMS_SLOTS)

set_ems_discharge_slot_start(idx, t)

Set just the start of EMS plant discharge slot idx (1-3), or clear it if None.

Source code in givenergy_modbus/client/commands.py
659
660
661
def set_ems_discharge_slot_start(idx: int, t: dt_time | None) -> list[TransparentRequest]:
    """Set just the start of EMS plant discharge slot idx (1-3), or clear it if None."""
    return set_discharge_slot_start(idx, t, EMS_SLOTS)

set_ems_discharge_target_soc(idx, target_soc)

Set the SoC target (0-100%) for EMS plant discharge slot idx (1-3).

Source code in givenergy_modbus/client/commands.py
679
680
681
682
683
684
685
686
687
688
def set_ems_discharge_target_soc(idx: int, target_soc: int) -> list[TransparentRequest]:
    """Set the SoC target (0-100%) for EMS plant discharge slot idx (1-3)."""
    idx = _as_int(idx, "idx")
    if not 1 <= idx <= 3:
        raise ValueError(f"EMS discharge slot index ({idx}) must be in [1-3]")
    return [
        WriteHoldingRegisterRequest(
            getattr(RegisterMap, f"EMS_DISCHARGE_TARGET_SOC_{idx}"), _ems_target_soc(target_soc)
        )
    ]

set_ems_export_power_limit(watts)

Set the EMS plant export power limit in watts.

Bounded to a 16-bit holding register (0-65535) so an out-of-range value fails here rather than later at PDU-encode time as InvalidPduState.

Source code in givenergy_modbus/client/commands.py
721
722
723
724
725
726
727
728
729
730
def set_ems_export_power_limit(watts: int) -> list[TransparentRequest]:
    """Set the EMS plant export power limit in watts.

    Bounded to a 16-bit holding register (0-65535) so an out-of-range value fails
    here rather than later at PDU-encode time as InvalidPduState.
    """
    watts = _as_int(watts, "watts")
    if not 0 <= watts <= 0xFFFF:
        raise ValueError(f"EMS export power limit ({watts}) must be in [0-65535] watts")
    return [WriteHoldingRegisterRequest(RegisterMap.EMS_EXPORT_POWER_LIMIT, watts)]

set_ems_export_slot(idx, timeslot)

Set an EMS plant export time slot by index (1-3), or clear it if None.

EMS export slots are the same HR(2062-2069) registers as set_export_slot (export slots are EMS-only and already target the EMS address 0x11) — this is the EMS-named alias for parity with set_ems_charge_slot/set_ems_discharge_slot.

Source code in givenergy_modbus/client/commands.py
691
692
693
694
695
696
697
698
def set_ems_export_slot(idx: int, timeslot: TimeSlot | None) -> list[TransparentRequest]:
    """Set an EMS plant export time slot by index (1-3), or clear it if None.

    EMS export slots are the same HR(2062-2069) registers as `set_export_slot`
    (export slots are EMS-only and already target the EMS address 0x11) — this is
    the EMS-named alias for parity with `set_ems_charge_slot`/`set_ems_discharge_slot`.
    """
    return set_export_slot(idx, timeslot)

set_ems_export_slot_end(idx, t)

Set just the end of EMS plant export slot idx (1-3), or clear it if None.

Source code in givenergy_modbus/client/commands.py
706
707
708
def set_ems_export_slot_end(idx: int, t: dt_time | None) -> list[TransparentRequest]:
    """Set just the end of EMS plant export slot idx (1-3), or clear it if None."""
    return set_export_slot_end(idx, t)

set_ems_export_slot_start(idx, t)

Set just the start of EMS plant export slot idx (1-3), or clear it if None.

Source code in givenergy_modbus/client/commands.py
701
702
703
def set_ems_export_slot_start(idx: int, t: dt_time | None) -> list[TransparentRequest]:
    """Set just the start of EMS plant export slot idx (1-3), or clear it if None."""
    return set_export_slot_start(idx, t)

set_ems_export_target_soc(idx, target_soc)

Set the SoC target (0-100%) for EMS plant export slot idx (1-3).

Source code in givenergy_modbus/client/commands.py
711
712
713
714
715
716
717
718
def set_ems_export_target_soc(idx: int, target_soc: int) -> list[TransparentRequest]:
    """Set the SoC target (0-100%) for EMS plant export slot idx (1-3)."""
    idx = _as_int(idx, "idx")
    if not 1 <= idx <= 3:
        raise ValueError(f"EMS export slot index ({idx}) must be in [1-3]")
    return [
        WriteHoldingRegisterRequest(getattr(RegisterMap, f"EMS_EXPORT_TARGET_SOC_{idx}"), _ems_target_soc(target_soc))
    ]

set_ems_plant(enabled)

Enable EMS plant control.

Source code in givenergy_modbus/client/commands.py
595
596
597
def set_ems_plant(enabled: bool) -> list[TransparentRequest]:
    """Enable EMS plant control."""
    return [WriteHoldingRegisterRequest(RegisterMap.EMS_PLANT_ENABLE, 1 if enabled else 0)]

set_enable_charge(enabled)

Enable the battery to charge, depending on the mode and slots set.

Source code in givenergy_modbus/client/commands.py
260
261
262
def set_enable_charge(enabled: bool) -> list[TransparentRequest]:
    """Enable the battery to charge, depending on the mode and slots set."""
    return [WriteHoldingRegisterRequest(RegisterMap.ENABLE_CHARGE, 1 if enabled else 0)]

set_enable_discharge(enabled)

Enable the battery to discharge, depending on the mode and slots set.

Source code in givenergy_modbus/client/commands.py
265
266
267
def set_enable_discharge(enabled: bool) -> list[TransparentRequest]:
    """Enable the battery to discharge, depending on the mode and slots set."""
    return [WriteHoldingRegisterRequest(RegisterMap.ENABLE_DISCHARGE, 1 if enabled else 0)]

set_enable_eps(enabled)

Enable or disable Emergency Power Supply (EPS) mode on AC-coupled inverters.

Confirmed writable on Model.AC via direct portal observations (hass#52).

Source code in givenergy_modbus/client/commands.py
495
496
497
498
499
500
def set_enable_eps(enabled: bool) -> list[TransparentRequest]:
    """Enable or disable Emergency Power Supply (EPS) mode on AC-coupled inverters.

    Confirmed writable on Model.AC via direct portal observations (hass#52).
    """
    return [WriteHoldingRegisterRequest(RegisterMap.ENABLE_EPS, 1 if enabled else 0)]

set_enable_ev_charger(enabled)

Enable or disable the EV charger (HR333). Installer-tier.

Source code in givenergy_modbus/client/commands.py
960
961
962
def set_enable_ev_charger(enabled: bool) -> list[WriteHoldingRegisterRequest]:
    """Enable or disable the EV charger (HR333). Installer-tier."""
    return [WriteHoldingRegisterRequest(RegisterMap.ENABLE_EV_CHARGER, 1 if enabled else 0, installer=True)]

set_enable_export_limit_3ph(enabled)

Enable or disable export limit on three-phase inverters (HR1103). Installer-tier.

Source code in givenergy_modbus/client/commands.py
1036
1037
1038
def set_enable_export_limit_3ph(enabled: bool) -> list[WriteHoldingRegisterRequest]:
    """Enable or disable export limit on three-phase inverters (HR1103). Installer-tier."""
    return [WriteHoldingRegisterRequest(RegisterMap.ENABLE_EXPORT_LIMIT_3PH, 1 if enabled else 0, installer=True)]

set_enable_generator(enabled)

Enable or disable the generator (HR343). Installer-tier.

Source code in givenergy_modbus/client/commands.py
973
974
975
def set_enable_generator(enabled: bool) -> list[WriteHoldingRegisterRequest]:
    """Enable or disable the generator (HR343). Installer-tier."""
    return [WriteHoldingRegisterRequest(RegisterMap.ENABLE_GENERATOR, 1 if enabled else 0, installer=True)]

set_enable_import_limit_3ph(enabled)

Enable or disable import limit on three-phase inverters (HR1131). Installer-tier.

Source code in givenergy_modbus/client/commands.py
1041
1042
1043
def set_enable_import_limit_3ph(enabled: bool) -> list[WriteHoldingRegisterRequest]:
    """Enable or disable import limit on three-phase inverters (HR1131). Installer-tier."""
    return [WriteHoldingRegisterRequest(RegisterMap.ENABLE_IMPORT_LIMIT_3PH, 1 if enabled else 0, installer=True)]

set_enable_micro_grid(enabled)

Enable or disable micro grid mode (HR332). Installer-tier.

Source code in givenergy_modbus/client/commands.py
955
956
957
def set_enable_micro_grid(enabled: bool) -> list[WriteHoldingRegisterRequest]:
    """Enable or disable micro grid mode (HR332). Installer-tier."""
    return [WriteHoldingRegisterRequest(RegisterMap.ENABLE_MICRO_GRID, 1 if enabled else 0, installer=True)]

set_enable_plant_mode(enabled)

Enable or disable plant mode (HR300). Installer-tier.

Source code in givenergy_modbus/client/commands.py
950
951
952
def set_enable_plant_mode(enabled: bool) -> list[WriteHoldingRegisterRequest]:
    """Enable or disable plant mode (HR300). Installer-tier."""
    return [WriteHoldingRegisterRequest(RegisterMap.ENABLE_PLANT_MODE, 1 if enabled else 0, installer=True)]

set_enable_rtc(enabled)

Enable the Real Time Clock register to persist settings to EEPROM.

Source code in givenergy_modbus/client/commands.py
473
474
475
def set_enable_rtc(enabled: bool) -> list[TransparentRequest]:
    """Enable the Real Time Clock register to persist settings to EEPROM."""
    return [WriteHoldingRegisterRequest(RegisterMap.ENABLE_RTC, 1 if enabled else 0)]

set_enable_smart_load(enabled)

Enable or disable smart load (HR540). Installer-tier.

Source code in givenergy_modbus/client/commands.py
994
995
996
def set_enable_smart_load(enabled: bool) -> list[WriteHoldingRegisterRequest]:
    """Enable or disable smart load (HR540). Installer-tier."""
    return [WriteHoldingRegisterRequest(RegisterMap.ENABLE_SMART_LOAD, 1 if enabled else 0, installer=True)]

set_ev_charger_soc_limit(soc)

Set EV charger SOC limit (HR336). Installer-tier. Range: 0–100 %.

Source code in givenergy_modbus/client/commands.py
965
966
967
968
969
970
def set_ev_charger_soc_limit(soc: int) -> list[WriteHoldingRegisterRequest]:
    """Set EV charger SOC limit (HR336). Installer-tier. Range: 0–100 %."""
    val = _as_int(soc, "soc")
    if not (0 <= val <= 100):
        raise ValueError(f"EV charger SOC limit must be 0–100, got {val}")
    return [WriteHoldingRegisterRequest(RegisterMap.EV_CHARGER_SOC_LIMIT, val, installer=True)]

set_export_power_rate(rate)

Set the three-phase export power rate cap (HR1063) as a percentage of rated power. Installer-tier.

rate is a percentage in [0, 100]; the register is written in its native 0.1% units (raw = round(rate * 10), 0~1000). HR1063 is confirmed present on three-phase hardware via the installer app (EXPORT_LIMIT_POWER_SET), but the write itself hasn't been validated against a real three-phase unit yet — see #263. Use export_power_rate to read it back.

Source code in givenergy_modbus/client/commands.py
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
def set_export_power_rate(rate: float) -> list[WriteHoldingRegisterRequest]:
    """Set the three-phase export power rate cap (HR1063) as a percentage of rated power. Installer-tier.

    ``rate`` is a percentage in [0, 100]; the register is written in its native 0.1% units
    (raw = round(rate * 10), 0~1000). HR1063 is confirmed present on three-phase hardware via the
    installer app (EXPORT_LIMIT_POWER_SET), but the write itself hasn't been validated against a
    real three-phase unit yet — see #263. Use `export_power_rate` to read it back.
    """
    if not 0 <= rate <= 100:
        raise ValueError(f"Export power rate ({rate}) must be in [0-100]%")
    return [WriteHoldingRegisterRequest(RegisterMap.EXPORT_POWER_RATE, round(rate * 10), installer=True)]

set_export_priority(priority)

Set the export priority for surplus power on AC-coupled inverters.

Determines where surplus energy goes: battery first, grid first, or load first. Confirmed writable on Model.AC via direct portal observations (hass#52).

Source code in givenergy_modbus/client/commands.py
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
def set_export_priority(priority: ExportPriority) -> list[TransparentRequest]:
    """Set the export priority for surplus power on AC-coupled inverters.

    Determines where surplus energy goes: battery first, grid first, or load first.
    Confirmed writable on Model.AC via direct portal observations (hass#52).
    """
    # bool subclasses int, so ExportPriority(True) would resolve to BATTERY_FIRST (1) and pass as an
    # IntEnum — silently selecting a write mode. Reject it before the enum conversion (audit L1).
    if isinstance(priority, bool):
        raise ValueError(f"Export priority must be an ExportPriority, not bool (got {priority!r})")
    try:
        priority = ExportPriority(priority)
    except ValueError as e:
        raise ValueError(f"Invalid export priority: {priority}") from e
    return [WriteHoldingRegisterRequest(RegisterMap.EXPORT_PRIORITY, priority)]

set_export_slot(idx, slot)

Set an export time slot by index (1–3), or clear it if slot is None.

Source code in givenergy_modbus/client/commands.py
619
620
621
622
623
624
def set_export_slot(idx: int, slot: TimeSlot | None) -> list[TransparentRequest]:
    """Set an export time slot by index (1–3), or clear it if slot is None."""
    _export_slot_registers(idx)  # index validation
    start = slot.start if slot else None
    end = slot.end if slot else None
    return set_export_slot_start(idx, start) + set_export_slot_end(idx, end)

set_export_slot_end(idx, t)

Set just the end of an export time slot by index (1–3), or clear it if t is None.

Source code in givenergy_modbus/client/commands.py
613
614
615
616
def set_export_slot_end(idx: int, t: dt_time | None) -> list[TransparentRequest]:
    """Set just the end of an export time slot by index (1–3), or clear it if t is None."""
    _, hr_end = _export_slot_registers(idx)
    return _set_slot_endpoint(hr_end, t)

set_export_slot_start(idx, t)

Set just the start of an export time slot by index (1–3), or clear it if t is None.

Source code in givenergy_modbus/client/commands.py
607
608
609
610
def set_export_slot_start(idx: int, t: dt_time | None) -> list[TransparentRequest]:
    """Set just the start of an export time slot by index (1–3), or clear it if t is None."""
    hr_start, _ = _export_slot_registers(idx)
    return _set_slot_endpoint(hr_start, t)

set_f_ac_high_limit_grid(freq, *, confirm=False)

Set AC over-frequency grid-band threshold (HR82). Installer-tier. Range 40.0–70.0 Hz.

Part of the G98/G99/G100 grid band. Pass confirm=True after verifying grid-code compliance. Value is written as centi (int(freq × 100)).

Source code in givenergy_modbus/client/commands.py
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
def set_f_ac_high_limit_grid(freq: float, *, confirm: bool = False) -> list[WriteHoldingRegisterRequest]:
    """Set AC over-frequency grid-band threshold (HR82). Installer-tier. Range 40.0–70.0 Hz.

    Part of the G98/G99/G100 grid band. Pass confirm=True after verifying grid-code compliance.
    Value is written as centi (int(freq × 100)).
    """
    _grid_confirm("set_f_ac_high_limit_grid", confirm)
    val = _as_scaled_int(freq, 100, "freq")
    if not (4000 <= val <= 7000):
        raise ValueError(f"f_ac_high_limit_grid must be 40.0–70.0 Hz, got {freq}")
    return [WriteHoldingRegisterRequest(RegisterMap.F_AC_HIGH_LIMIT_GRID, val, installer=True)]

set_f_ac_high_limit_reconnect(freq, *, confirm=False)

Set AC over-frequency reconnect threshold (HR74). Installer-tier. Range 40.0–70.0 Hz.

Part of the G98/G99/G100 reconnect band. Pass confirm=True after verifying grid-code compliance. Value is written as centi (int(freq × 100)).

Source code in givenergy_modbus/client/commands.py
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
def set_f_ac_high_limit_reconnect(freq: float, *, confirm: bool = False) -> list[WriteHoldingRegisterRequest]:
    """Set AC over-frequency reconnect threshold (HR74). Installer-tier. Range 40.0–70.0 Hz.

    Part of the G98/G99/G100 reconnect band. Pass confirm=True after verifying grid-code compliance.
    Value is written as centi (int(freq × 100)).
    """
    _grid_confirm("set_f_ac_high_limit_reconnect", confirm)
    val = _as_scaled_int(freq, 100, "freq")
    if not (4000 <= val <= 7000):
        raise ValueError(f"f_ac_high_limit_reconnect must be 40.0–70.0 Hz, got {freq}")
    return [WriteHoldingRegisterRequest(RegisterMap.F_AC_HIGH_LIMIT_RECONNECT, val, installer=True)]

set_f_ac_high_limit_trip(freq, *, confirm=False)

Set AC over-frequency trip threshold (HR66). Installer-tier. Range 40.0–70.0 Hz.

Part of the G98/G99/G100 trip band. Pass confirm=True after verifying grid-code compliance. Value is written as centi (int(freq × 100)).

Source code in givenergy_modbus/client/commands.py
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
def set_f_ac_high_limit_trip(freq: float, *, confirm: bool = False) -> list[WriteHoldingRegisterRequest]:
    """Set AC over-frequency trip threshold (HR66). Installer-tier. Range 40.0–70.0 Hz.

    Part of the G98/G99/G100 trip band. Pass confirm=True after verifying grid-code compliance.
    Value is written as centi (int(freq × 100)).
    """
    _grid_confirm("set_f_ac_high_limit_trip", confirm)
    val = _as_scaled_int(freq, 100, "freq")
    if not (4000 <= val <= 7000):
        raise ValueError(f"f_ac_high_limit_trip must be 40.0–70.0 Hz, got {freq}")
    return [WriteHoldingRegisterRequest(RegisterMap.F_AC_HIGH_LIMIT_TRIP, val, installer=True)]

set_f_ac_low_limit_grid(freq, *, confirm=False)

Set AC under-frequency grid-band threshold (HR81). Installer-tier. Range 40.0–70.0 Hz.

Part of the G98/G99/G100 grid band. Pass confirm=True after verifying grid-code compliance. Value is written as centi (int(freq × 100)).

Source code in givenergy_modbus/client/commands.py
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
def set_f_ac_low_limit_grid(freq: float, *, confirm: bool = False) -> list[WriteHoldingRegisterRequest]:
    """Set AC under-frequency grid-band threshold (HR81). Installer-tier. Range 40.0–70.0 Hz.

    Part of the G98/G99/G100 grid band. Pass confirm=True after verifying grid-code compliance.
    Value is written as centi (int(freq × 100)).
    """
    _grid_confirm("set_f_ac_low_limit_grid", confirm)
    val = _as_scaled_int(freq, 100, "freq")
    if not (4000 <= val <= 7000):
        raise ValueError(f"f_ac_low_limit_grid must be 40.0–70.0 Hz, got {freq}")
    return [WriteHoldingRegisterRequest(RegisterMap.F_AC_LOW_LIMIT_GRID, val, installer=True)]

set_f_ac_low_limit_reconnect(freq, *, confirm=False)

Set AC under-frequency reconnect threshold (HR73). Installer-tier. Range 40.0–70.0 Hz.

Part of the G98/G99/G100 reconnect band. Pass confirm=True after verifying grid-code compliance. Value is written as centi (int(freq × 100)).

Source code in givenergy_modbus/client/commands.py
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
def set_f_ac_low_limit_reconnect(freq: float, *, confirm: bool = False) -> list[WriteHoldingRegisterRequest]:
    """Set AC under-frequency reconnect threshold (HR73). Installer-tier. Range 40.0–70.0 Hz.

    Part of the G98/G99/G100 reconnect band. Pass confirm=True after verifying grid-code compliance.
    Value is written as centi (int(freq × 100)).
    """
    _grid_confirm("set_f_ac_low_limit_reconnect", confirm)
    val = _as_scaled_int(freq, 100, "freq")
    if not (4000 <= val <= 7000):
        raise ValueError(f"f_ac_low_limit_reconnect must be 40.0–70.0 Hz, got {freq}")
    return [WriteHoldingRegisterRequest(RegisterMap.F_AC_LOW_LIMIT_RECONNECT, val, installer=True)]

set_f_ac_low_limit_trip(freq, *, confirm=False)

Set AC under-frequency trip threshold (HR65). Installer-tier. Range 40.0–70.0 Hz.

Part of the G98/G99/G100 trip band. Pass confirm=True after verifying grid-code compliance. Value is written as centi (int(freq × 100)).

Source code in givenergy_modbus/client/commands.py
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
def set_f_ac_low_limit_trip(freq: float, *, confirm: bool = False) -> list[WriteHoldingRegisterRequest]:
    """Set AC under-frequency trip threshold (HR65). Installer-tier. Range 40.0–70.0 Hz.

    Part of the G98/G99/G100 trip band. Pass confirm=True after verifying grid-code compliance.
    Value is written as centi (int(freq × 100)).
    """
    _grid_confirm("set_f_ac_low_limit_trip", confirm)
    val = _as_scaled_int(freq, 100, "freq")
    if not (4000 <= val <= 7000):
        raise ValueError(f"f_ac_low_limit_trip must be 40.0–70.0 Hz, got {freq}")
    return [WriteHoldingRegisterRequest(RegisterMap.F_AC_LOW_LIMIT_TRIP, val, installer=True)]

set_force_charge(enabled)

Enable forced battery charging on three-phase inverters.

Source code in givenergy_modbus/client/commands.py
585
586
587
def set_force_charge(enabled: bool) -> list[TransparentRequest]:
    """Enable forced battery charging on three-phase inverters."""
    return [WriteHoldingRegisterRequest(RegisterMap.FORCE_CHARGE_ENABLE, 1 if enabled else 0)]

set_force_discharge(enabled)

Enable forced battery discharging on three-phase inverters.

Source code in givenergy_modbus/client/commands.py
590
591
592
def set_force_discharge(enabled: bool) -> list[TransparentRequest]:
    """Enable forced battery discharging on three-phase inverters."""
    return [WriteHoldingRegisterRequest(RegisterMap.FORCE_DISCHARGE_ENABLE, 1 if enabled else 0)]

set_general_load_control_soc(soc)

Set general load control SOC (HR543). Installer-tier. App range: 50–100 %.

Source code in givenergy_modbus/client/commands.py
1007
1008
1009
1010
1011
1012
def set_general_load_control_soc(soc: int) -> list[WriteHoldingRegisterRequest]:
    """Set general load control SOC (HR543). Installer-tier. App range: 50–100 %."""
    val = _as_int(soc, "soc")
    if not (50 <= val <= 100):
        raise ValueError(f"General load control SOC must be 50–100, got {val}")
    return [WriteHoldingRegisterRequest(RegisterMap.GENERAL_LOAD_CONTROL_SOC, val, installer=True)]

set_generator_control_soc(soc)

Set generator control SOC (HR544). Installer-tier. App range: 10–90 %.

Source code in givenergy_modbus/client/commands.py
1015
1016
1017
1018
1019
1020
def set_generator_control_soc(soc: int) -> list[WriteHoldingRegisterRequest]:
    """Set generator control SOC (HR544). Installer-tier. App range: 10–90 %."""
    val = _as_int(soc, "soc")
    if not (10 <= val <= 90):
        raise ValueError(f"Generator control SOC must be 10–90, got {val}")
    return [WriteHoldingRegisterRequest(RegisterMap.GENERATOR_CONTROL_SOC, val, installer=True)]

set_generator_start_soc(soc)

Set generator start SOC threshold (HR344). Installer-tier. Range: 0–100 %.

Source code in givenergy_modbus/client/commands.py
978
979
980
981
982
983
def set_generator_start_soc(soc: int) -> list[WriteHoldingRegisterRequest]:
    """Set generator start SOC threshold (HR344). Installer-tier. Range: 0–100 %."""
    val = _as_int(soc, "soc")
    if not (0 <= val <= 100):
        raise ValueError(f"Generator start SOC must be 0–100, got {val}")
    return [WriteHoldingRegisterRequest(RegisterMap.GENERATOR_START_SOC, val, installer=True)]

set_generator_stop_soc(soc)

Set generator stop SOC threshold (HR345). Installer-tier. Range: 0–100 %.

Source code in givenergy_modbus/client/commands.py
986
987
988
989
990
991
def set_generator_stop_soc(soc: int) -> list[WriteHoldingRegisterRequest]:
    """Set generator stop SOC threshold (HR345). Installer-tier. Range: 0–100 %."""
    val = _as_int(soc, "soc")
    if not (0 <= val <= 100):
        raise ValueError(f"Generator stop SOC must be 0–100, got {val}")
    return [WriteHoldingRegisterRequest(RegisterMap.GENERATOR_STOP_SOC, val, installer=True)]

set_grid_import_limit_enabled(enabled)

Enable or disable the grid import limit (HR102). Installer-tier.

Source code in givenergy_modbus/client/commands.py
945
946
947
def set_grid_import_limit_enabled(enabled: bool) -> list[WriteHoldingRegisterRequest]:
    """Enable or disable the grid import limit (HR102). Installer-tier."""
    return [WriteHoldingRegisterRequest(RegisterMap.GRID_IMPORT_LIMIT_ENABLED, 1 if enabled else 0, installer=True)]

set_inverter_reboot()

Restart the inverter.

Source code in givenergy_modbus/client/commands.py
270
271
272
def set_inverter_reboot() -> list[TransparentRequest]:
    """Restart the inverter."""
    return [WriteHoldingRegisterRequest(RegisterMap.REBOOT, 100)]

set_mode_dynamic()

Set system to Dynamic / Eco mode.

This mode is designed to maximise use of solar generation. The battery will charge from excess solar generation to avoid exporting power, and discharge to meet load demand when solar power is insufficient to avoid importing power. This mode is useful if you want to maximise self-consumption of renewable generation and minimise the amount of energy drawn from the grid.

Source code in givenergy_modbus/client/commands.py
860
861
862
863
864
865
866
867
868
869
def set_mode_dynamic() -> list[TransparentRequest]:
    """Set system to Dynamic / Eco mode.

    This mode is designed to maximise use of solar generation. The battery will charge from excess solar
    generation to avoid exporting power, and discharge to meet load demand when solar power is insufficient to
    avoid importing power. This mode is useful if you want to maximise self-consumption of renewable generation
    and minimise the amount of energy drawn from the grid.
    """
    # r27=1 r110=4 r59=0
    return set_discharge_mode_to_match_demand() + set_battery_soc_reserve(4) + set_enable_discharge(False)

set_mode_storage(discharge_slot_1=TimeSlot.from_repr(1600, 700), discharge_slot_2=None, discharge_for_export=False, slot_map=SINGLE_PHASE_SLOTS)

Set system to storage mode with specific discharge slots(s).

This mode stores excess solar generation during the day and holds that energy ready for use later in the day. By default, the battery will start to discharge from 4pm-7am to cover energy demand during typical peak hours. This mode is particularly useful if you get charged more for your electricity at certain times to utilise the battery when it is most effective. If the second time slot isn't specified, it will be cleared.

You can optionally also choose to export excess energy: instead of discharging to meet only your load demand, the battery will discharge at full power and any excess will be exported to the grid. This is useful if you have a variable export tariff (e.g. Agile export) and you want to target the peak times of day (e.g. 4pm-7pm) when it is most valuable to export energy.

Source code in givenergy_modbus/client/commands.py
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
def set_mode_storage(
    discharge_slot_1: TimeSlot = TimeSlot.from_repr(1600, 700),
    discharge_slot_2: TimeSlot | None = None,
    discharge_for_export: bool = False,
    slot_map: SlotMap = SINGLE_PHASE_SLOTS,
) -> list[TransparentRequest]:
    """Set system to storage mode with specific discharge slots(s).

    This mode stores excess solar generation during the day and holds that energy ready for use later in the day.
    By default, the battery will start to discharge from 4pm-7am to cover energy demand during typical peak
    hours. This mode is particularly useful if you get charged more for your electricity at certain times to
    utilise the battery when it is most effective. If the second time slot isn't specified, it will be cleared.

    You can optionally also choose to export excess energy: instead of discharging to meet only your load demand,
    the battery will discharge at full power and any excess will be exported to the grid. This is useful if you
    have a variable export tariff (e.g. Agile export) and you want to target the peak times of day (e.g. 4pm-7pm)
    when it is most valuable to export energy.
    """
    if discharge_for_export:
        ret = set_discharge_mode_max_power()  # r27=0
    else:
        ret = set_discharge_mode_to_match_demand()  # r27=1
    # Intentionally do not set BATTERY_SOC_RESERVE (r110): forcing it to 100
    # disables discharge entirely on Gen2 (and is not what the official portal
    # does when selecting Timed Discharge / Timed Export presets). Callers who
    # want a specific reserve should set it explicitly via set_battery_soc_reserve().
    ret.extend(set_enable_discharge(True))  # r59=1
    ret.extend(set_discharge_slot(1, discharge_slot_1, slot_map))
    if discharge_slot_2:
        ret.extend(set_discharge_slot(2, discharge_slot_2, slot_map))
    else:
        ret.extend(reset_discharge_slot(2, slot_map))
    return ret

set_pause_slot(slot)

Set the battery pause time slot, or clear it if slot is None.

Source code in givenergy_modbus/client/commands.py
550
551
552
553
554
def set_pause_slot(slot: TimeSlot | None) -> list[TransparentRequest]:
    """Set the battery pause time slot, or clear it if slot is None."""
    start = slot.start if slot else None
    end = slot.end if slot else None
    return set_pause_slot_start(start) + set_pause_slot_end(end)

set_pause_slot_end(t)

Set just the end of the battery pause slot, or clear it if t is None.

Source code in givenergy_modbus/client/commands.py
545
546
547
def set_pause_slot_end(t: dt_time | None) -> list[TransparentRequest]:
    """Set just the end of the battery pause slot, or clear it if t is None."""
    return _set_slot_endpoint(RegisterMap.BATTERY_PAUSE_SLOT_END, t)

set_pause_slot_start(t)

Set just the start of the battery pause slot, or clear it if t is None.

Source code in givenergy_modbus/client/commands.py
540
541
542
def set_pause_slot_start(t: dt_time | None) -> list[TransparentRequest]:
    """Set just the start of the battery pause slot, or clear it if t is None."""
    return _set_slot_endpoint(RegisterMap.BATTERY_PAUSE_SLOT_START, t)

set_peak_shaving_enabled(enabled)

Enable or disable peak shaving (HR20002). Installer-tier.

Source code in givenergy_modbus/client/commands.py
1052
1053
1054
def set_peak_shaving_enabled(enabled: bool) -> list[WriteHoldingRegisterRequest]:
    """Enable or disable peak shaving (HR20002). Installer-tier."""
    return [WriteHoldingRegisterRequest(RegisterMap.PEAK_SHAVING_ENABLED, 1 if enabled else 0, installer=True)]

set_peak_shaving_export_limit_enabled(enabled)

Enable or disable peak-shaving grid export limit (HR20000). Installer-tier.

Source code in givenergy_modbus/client/commands.py
1046
1047
1048
1049
def set_peak_shaving_export_limit_enabled(enabled: bool) -> list[WriteHoldingRegisterRequest]:
    """Enable or disable peak-shaving grid export limit (HR20000). Installer-tier."""
    val = 1 if enabled else 0
    return [WriteHoldingRegisterRequest(RegisterMap.PEAK_SHAVING_EXPORT_LIMIT_ENABLED, val, installer=True)]

set_shallow_charge(val)

Set the minimum level of charge to maintain.

Source code in givenergy_modbus/client/commands.py
319
320
321
322
@deprecated("Use set_battery_soc_reserve(val) instead")
def set_shallow_charge(val: int) -> list[TransparentRequest]:
    """Set the minimum level of charge to maintain."""
    return set_battery_soc_reserve(val)

set_smart_load_control_soc(soc)

Set smart load control SOC (HR541). Installer-tier. App range: 50–100 %.

Source code in givenergy_modbus/client/commands.py
 999
1000
1001
1002
1003
1004
def set_smart_load_control_soc(soc: int) -> list[WriteHoldingRegisterRequest]:
    """Set smart load control SOC (HR541). Installer-tier. App range: 50–100 %."""
    val = _as_int(soc, "soc")
    if not (50 <= val <= 100):
        raise ValueError(f"Smart load control SOC must be 50–100, got {val}")
    return [WriteHoldingRegisterRequest(RegisterMap.SMART_LOAD_CONTROL_SOC, val, installer=True)]

set_smart_load_slot(idx, slot)

Set Smart Load slot idx (1-based, 1–10) atomically, or clear it if slot is None.

Source code in givenergy_modbus/client/commands.py
573
574
575
576
577
def set_smart_load_slot(idx: int, slot: "TimeSlot | None") -> list[TransparentRequest]:
    """Set Smart Load slot *idx* (1-based, 1–10) atomically, or clear it if slot is None."""
    start = slot.start if slot else None
    end = slot.end if slot else None
    return set_smart_load_slot_start(idx, start) + set_smart_load_slot_end(idx, end)

set_smart_load_slot_end(idx, t)

Set the end time of Smart Load slot idx (1-based, 1–10), or clear it if t is None.

Source code in givenergy_modbus/client/commands.py
565
566
567
568
569
570
def set_smart_load_slot_end(idx: int, t: dt_time | None) -> list[TransparentRequest]:
    """Set the end time of Smart Load slot *idx* (1-based, 1–10), or clear it if t is None."""
    idx = _as_int(idx, "idx")
    if not (1 <= idx <= 10):
        raise ValueError(f"Smart Load slot index must be 1–10 (got {idx})")
    return _set_slot_endpoint(RegisterMap.SMART_LOAD_SLOT_1_START + (idx - 1) * 2 + 1, t)

set_smart_load_slot_start(idx, t)

Set the start time of Smart Load slot idx (1-based, 1–10), or clear it if t is None.

Source code in givenergy_modbus/client/commands.py
557
558
559
560
561
562
def set_smart_load_slot_start(idx: int, t: dt_time | None) -> list[TransparentRequest]:
    """Set the start time of Smart Load slot *idx* (1-based, 1–10), or clear it if t is None."""
    idx = _as_int(idx, "idx")
    if not (1 <= idx <= 10):
        raise ValueError(f"Smart Load slot index must be 1–10 (got {idx})")
    return _set_slot_endpoint(RegisterMap.SMART_LOAD_SLOT_1_START + (idx - 1) * 2, t)

set_system_date_time(dt)

Set the date & time of the inverter.

Source code in givenergy_modbus/client/commands.py
844
845
846
847
848
849
850
851
852
853
854
855
856
857
def set_system_date_time(dt: datetime) -> list[TransparentRequest]:
    """Set the date & time of the inverter."""
    if dt.year < 2000:
        # The year register stores `year - 2000`; a pre-2000 year underflows to a negative
        # value and a confusing encode-time InvalidPduState. Reject it clearly up front (L6).
        raise ValueError(f"System date year ({dt.year}) must be >= 2000")
    return [
        WriteHoldingRegisterRequest(RegisterMap.SYSTEM_TIME_YEAR, dt.year - 2000),
        WriteHoldingRegisterRequest(RegisterMap.SYSTEM_TIME_MONTH, dt.month),
        WriteHoldingRegisterRequest(RegisterMap.SYSTEM_TIME_DAY, dt.day),
        WriteHoldingRegisterRequest(RegisterMap.SYSTEM_TIME_HOUR, dt.hour),
        WriteHoldingRegisterRequest(RegisterMap.SYSTEM_TIME_MINUTE, dt.minute),
        WriteHoldingRegisterRequest(RegisterMap.SYSTEM_TIME_SECOND, dt.second),
    ]

set_t_ac_high_freq_reconnect(seconds, *, confirm=False)

Set AC over-frequency reconnect time (HR78). Installer-tier. Value in seconds (centi-scaled).

Part of the G98/G99/G100 reconnect band. Pass confirm=True after verifying grid-code compliance. Value is written as centi (int(seconds × 100)).

Source code in givenergy_modbus/client/commands.py
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
def set_t_ac_high_freq_reconnect(seconds: float, *, confirm: bool = False) -> list[WriteHoldingRegisterRequest]:
    """Set AC over-frequency reconnect time (HR78). Installer-tier. Value in seconds (centi-scaled).

    Part of the G98/G99/G100 reconnect band. Pass confirm=True after verifying grid-code compliance.
    Value is written as centi (int(seconds × 100)).
    """
    _grid_confirm("set_t_ac_high_freq_reconnect", confirm)
    val = _as_scaled_int(seconds, 100, "seconds")
    if not (0 <= val <= 65535):
        raise ValueError(f"t_ac_high_freq_reconnect must be 0.0–655.35 s, got {seconds}")
    return [WriteHoldingRegisterRequest(RegisterMap.T_AC_HIGH_FREQ_RECONNECT, val, installer=True)]

set_t_ac_high_freq_trip(seconds, *, confirm=False)

Set AC over-frequency trip time (HR70). Installer-tier. Value in seconds (centi-scaled).

Part of the G98/G99/G100 trip band. Pass confirm=True after verifying grid-code compliance. Value is written as centi (int(seconds × 100)).

Source code in givenergy_modbus/client/commands.py
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
def set_t_ac_high_freq_trip(seconds: float, *, confirm: bool = False) -> list[WriteHoldingRegisterRequest]:
    """Set AC over-frequency trip time (HR70). Installer-tier. Value in seconds (centi-scaled).

    Part of the G98/G99/G100 trip band. Pass confirm=True after verifying grid-code compliance.
    Value is written as centi (int(seconds × 100)).
    """
    _grid_confirm("set_t_ac_high_freq_trip", confirm)
    val = _as_scaled_int(seconds, 100, "seconds")
    if not (0 <= val <= 65535):
        raise ValueError(f"t_ac_high_freq_trip must be 0.0–655.35 s, got {seconds}")
    return [WriteHoldingRegisterRequest(RegisterMap.T_AC_HIGH_FREQ_TRIP, val, installer=True)]

set_t_ac_high_voltage_reconnect(seconds, *, confirm=False)

Set AC over-voltage reconnect time (HR76). Installer-tier. Value in seconds (centi-scaled).

Part of the G98/G99/G100 reconnect band. Pass confirm=True after verifying grid-code compliance. Value is written as centi (int(seconds × 100)).

Source code in givenergy_modbus/client/commands.py
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
def set_t_ac_high_voltage_reconnect(seconds: float, *, confirm: bool = False) -> list[WriteHoldingRegisterRequest]:
    """Set AC over-voltage reconnect time (HR76). Installer-tier. Value in seconds (centi-scaled).

    Part of the G98/G99/G100 reconnect band. Pass confirm=True after verifying grid-code compliance.
    Value is written as centi (int(seconds × 100)).
    """
    _grid_confirm("set_t_ac_high_voltage_reconnect", confirm)
    val = _as_scaled_int(seconds, 100, "seconds")
    if not (0 <= val <= 65535):
        raise ValueError(f"t_ac_high_voltage_reconnect must be 0.0–655.35 s, got {seconds}")
    return [WriteHoldingRegisterRequest(RegisterMap.T_AC_HIGH_VOLTAGE_RECONNECT, val, installer=True)]

set_t_ac_high_voltage_trip(seconds, *, confirm=False)

Set AC over-voltage trip time (HR68). Installer-tier. Value in seconds (centi-scaled).

Part of the G98/G99/G100 trip band. Pass confirm=True after verifying grid-code compliance. Value is written as centi (int(seconds × 100)).

Source code in givenergy_modbus/client/commands.py
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
def set_t_ac_high_voltage_trip(seconds: float, *, confirm: bool = False) -> list[WriteHoldingRegisterRequest]:
    """Set AC over-voltage trip time (HR68). Installer-tier. Value in seconds (centi-scaled).

    Part of the G98/G99/G100 trip band. Pass confirm=True after verifying grid-code compliance.
    Value is written as centi (int(seconds × 100)).
    """
    _grid_confirm("set_t_ac_high_voltage_trip", confirm)
    val = _as_scaled_int(seconds, 100, "seconds")
    if not (0 <= val <= 65535):
        raise ValueError(f"t_ac_high_voltage_trip must be 0.0–655.35 s, got {seconds}")
    return [WriteHoldingRegisterRequest(RegisterMap.T_AC_HIGH_VOLTAGE_TRIP, val, installer=True)]

set_t_ac_low_freq_reconnect(seconds, *, confirm=False)

Set AC under-frequency reconnect time (HR77). Installer-tier. Value in seconds (centi-scaled).

Part of the G98/G99/G100 reconnect band. Pass confirm=True after verifying grid-code compliance. Value is written as centi (int(seconds × 100)).

Source code in givenergy_modbus/client/commands.py
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
def set_t_ac_low_freq_reconnect(seconds: float, *, confirm: bool = False) -> list[WriteHoldingRegisterRequest]:
    """Set AC under-frequency reconnect time (HR77). Installer-tier. Value in seconds (centi-scaled).

    Part of the G98/G99/G100 reconnect band. Pass confirm=True after verifying grid-code compliance.
    Value is written as centi (int(seconds × 100)).
    """
    _grid_confirm("set_t_ac_low_freq_reconnect", confirm)
    val = _as_scaled_int(seconds, 100, "seconds")
    if not (0 <= val <= 65535):
        raise ValueError(f"t_ac_low_freq_reconnect must be 0.0–655.35 s, got {seconds}")
    return [WriteHoldingRegisterRequest(RegisterMap.T_AC_LOW_FREQ_RECONNECT, val, installer=True)]

set_t_ac_low_freq_trip(seconds, *, confirm=False)

Set AC under-frequency trip time (HR69). Installer-tier. Value in seconds (centi-scaled).

Part of the G98/G99/G100 trip band. Pass confirm=True after verifying grid-code compliance. Value is written as centi (int(seconds × 100)).

Source code in givenergy_modbus/client/commands.py
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
def set_t_ac_low_freq_trip(seconds: float, *, confirm: bool = False) -> list[WriteHoldingRegisterRequest]:
    """Set AC under-frequency trip time (HR69). Installer-tier. Value in seconds (centi-scaled).

    Part of the G98/G99/G100 trip band. Pass confirm=True after verifying grid-code compliance.
    Value is written as centi (int(seconds × 100)).
    """
    _grid_confirm("set_t_ac_low_freq_trip", confirm)
    val = _as_scaled_int(seconds, 100, "seconds")
    if not (0 <= val <= 65535):
        raise ValueError(f"t_ac_low_freq_trip must be 0.0–655.35 s, got {seconds}")
    return [WriteHoldingRegisterRequest(RegisterMap.T_AC_LOW_FREQ_TRIP, val, installer=True)]

set_t_ac_low_voltage_reconnect(seconds, *, confirm=False)

Set AC under-voltage reconnect time (HR75). Installer-tier. Value in seconds (centi-scaled).

Part of the G98/G99/G100 reconnect band. Pass confirm=True after verifying grid-code compliance. Value is written as centi (int(seconds × 100)).

Source code in givenergy_modbus/client/commands.py
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
def set_t_ac_low_voltage_reconnect(seconds: float, *, confirm: bool = False) -> list[WriteHoldingRegisterRequest]:
    """Set AC under-voltage reconnect time (HR75). Installer-tier. Value in seconds (centi-scaled).

    Part of the G98/G99/G100 reconnect band. Pass confirm=True after verifying grid-code compliance.
    Value is written as centi (int(seconds × 100)).
    """
    _grid_confirm("set_t_ac_low_voltage_reconnect", confirm)
    val = _as_scaled_int(seconds, 100, "seconds")
    if not (0 <= val <= 65535):
        raise ValueError(f"t_ac_low_voltage_reconnect must be 0.0–655.35 s, got {seconds}")
    return [WriteHoldingRegisterRequest(RegisterMap.T_AC_LOW_VOLTAGE_RECONNECT, val, installer=True)]

set_t_ac_low_voltage_trip(seconds, *, confirm=False)

Set AC under-voltage trip time (HR67). Installer-tier. Value in seconds (centi-scaled).

Part of the G98/G99/G100 trip band. Pass confirm=True after verifying grid-code compliance. Value is written as centi (int(seconds × 100)).

Source code in givenergy_modbus/client/commands.py
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
def set_t_ac_low_voltage_trip(seconds: float, *, confirm: bool = False) -> list[WriteHoldingRegisterRequest]:
    """Set AC under-voltage trip time (HR67). Installer-tier. Value in seconds (centi-scaled).

    Part of the G98/G99/G100 trip band. Pass confirm=True after verifying grid-code compliance.
    Value is written as centi (int(seconds × 100)).
    """
    _grid_confirm("set_t_ac_low_voltage_trip", confirm)
    val = _as_scaled_int(seconds, 100, "seconds")
    if not (0 <= val <= 65535):
        raise ValueError(f"t_ac_low_voltage_trip must be 0.0–655.35 s, got {seconds}")
    return [WriteHoldingRegisterRequest(RegisterMap.T_AC_LOW_VOLTAGE_TRIP, val, installer=True)]

set_v_ac_10min_protect(voltage, *, confirm=False)

Set 10-minute mean AC voltage protection threshold (HR83). Installer-tier. Range 0.0–500.0 V.

G98/G99/G100 10-minute mean voltage protection. Pass confirm=True after verifying grid-code compliance. Value is written as deci (int(voltage × 10)).

Source code in givenergy_modbus/client/commands.py
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
def set_v_ac_10min_protect(voltage: float, *, confirm: bool = False) -> list[WriteHoldingRegisterRequest]:
    """Set 10-minute mean AC voltage protection threshold (HR83). Installer-tier. Range 0.0–500.0 V.

    G98/G99/G100 10-minute mean voltage protection. Pass confirm=True after verifying grid-code compliance.
    Value is written as deci (int(voltage × 10)).
    """
    _grid_confirm("set_v_ac_10min_protect", confirm)
    val = _as_scaled_int(voltage, 10, "voltage")
    if not (0 <= val <= 5000):
        raise ValueError(f"v_ac_10min_protect must be 0.0–500.0 V, got {voltage}")
    return [WriteHoldingRegisterRequest(RegisterMap.V_AC_10MIN_PROTECT, val, installer=True)]

set_v_ac_high_limit_grid(voltage, *, confirm=False)

Set AC over-voltage grid-band threshold (HR80). Installer-tier. Range 0.0–500.0 V.

Part of the G98/G99/G100 grid band. Pass confirm=True after verifying grid-code compliance. Value is written as deci (int(voltage × 10)).

Source code in givenergy_modbus/client/commands.py
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
def set_v_ac_high_limit_grid(voltage: float, *, confirm: bool = False) -> list[WriteHoldingRegisterRequest]:
    """Set AC over-voltage grid-band threshold (HR80). Installer-tier. Range 0.0–500.0 V.

    Part of the G98/G99/G100 grid band. Pass confirm=True after verifying grid-code compliance.
    Value is written as deci (int(voltage × 10)).
    """
    _grid_confirm("set_v_ac_high_limit_grid", confirm)
    val = _as_scaled_int(voltage, 10, "voltage")
    if not (0 <= val <= 5000):
        raise ValueError(f"v_ac_high_limit_grid must be 0.0–500.0 V, got {voltage}")
    return [WriteHoldingRegisterRequest(RegisterMap.V_AC_HIGH_LIMIT_GRID, val, installer=True)]

set_v_ac_high_limit_reconnect(voltage, *, confirm=False)

Set AC over-voltage reconnect threshold (HR72). Installer-tier. Range 0.0–500.0 V.

Part of the G98/G99/G100 reconnect band. Pass confirm=True after verifying grid-code compliance. Value is written as deci (int(voltage × 10)).

Source code in givenergy_modbus/client/commands.py
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
def set_v_ac_high_limit_reconnect(voltage: float, *, confirm: bool = False) -> list[WriteHoldingRegisterRequest]:
    """Set AC over-voltage reconnect threshold (HR72). Installer-tier. Range 0.0–500.0 V.

    Part of the G98/G99/G100 reconnect band. Pass confirm=True after verifying grid-code compliance.
    Value is written as deci (int(voltage × 10)).
    """
    _grid_confirm("set_v_ac_high_limit_reconnect", confirm)
    val = _as_scaled_int(voltage, 10, "voltage")
    if not (0 <= val <= 5000):
        raise ValueError(f"v_ac_high_limit_reconnect must be 0.0–500.0 V, got {voltage}")
    return [WriteHoldingRegisterRequest(RegisterMap.V_AC_HIGH_LIMIT_RECONNECT, val, installer=True)]

set_v_ac_high_limit_trip(voltage, *, confirm=False)

Set AC over-voltage trip threshold (HR64). Installer-tier. Range 0.0–500.0 V.

Part of the G98/G99/G100 trip band. Pass confirm=True after verifying grid-code compliance. Value is written as deci (int(voltage × 10)).

Source code in givenergy_modbus/client/commands.py
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
def set_v_ac_high_limit_trip(voltage: float, *, confirm: bool = False) -> list[WriteHoldingRegisterRequest]:
    """Set AC over-voltage trip threshold (HR64). Installer-tier. Range 0.0–500.0 V.

    Part of the G98/G99/G100 trip band. Pass confirm=True after verifying grid-code compliance.
    Value is written as deci (int(voltage × 10)).
    """
    _grid_confirm("set_v_ac_high_limit_trip", confirm)
    val = _as_scaled_int(voltage, 10, "voltage")
    if not (0 <= val <= 5000):
        raise ValueError(f"v_ac_high_limit_trip must be 0.0–500.0 V, got {voltage}")
    return [WriteHoldingRegisterRequest(RegisterMap.V_AC_HIGH_LIMIT_TRIP, val, installer=True)]

set_v_ac_low_limit_grid(voltage, *, confirm=False)

Set AC under-voltage grid-band threshold (HR79). Installer-tier. Range 0.0–500.0 V.

Part of the G98/G99/G100 grid band. Pass confirm=True after verifying grid-code compliance. Value is written as deci (int(voltage × 10)).

Source code in givenergy_modbus/client/commands.py
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
def set_v_ac_low_limit_grid(voltage: float, *, confirm: bool = False) -> list[WriteHoldingRegisterRequest]:
    """Set AC under-voltage grid-band threshold (HR79). Installer-tier. Range 0.0–500.0 V.

    Part of the G98/G99/G100 grid band. Pass confirm=True after verifying grid-code compliance.
    Value is written as deci (int(voltage × 10)).
    """
    _grid_confirm("set_v_ac_low_limit_grid", confirm)
    val = _as_scaled_int(voltage, 10, "voltage")
    if not (0 <= val <= 5000):
        raise ValueError(f"v_ac_low_limit_grid must be 0.0–500.0 V, got {voltage}")
    return [WriteHoldingRegisterRequest(RegisterMap.V_AC_LOW_LIMIT_GRID, val, installer=True)]

set_v_ac_low_limit_reconnect(voltage, *, confirm=False)

Set AC under-voltage reconnect threshold (HR71). Installer-tier. Range 0.0–500.0 V.

Part of the G98/G99/G100 reconnect band. Pass confirm=True after verifying grid-code compliance. Value is written as deci (int(voltage × 10)).

Source code in givenergy_modbus/client/commands.py
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
def set_v_ac_low_limit_reconnect(voltage: float, *, confirm: bool = False) -> list[WriteHoldingRegisterRequest]:
    """Set AC under-voltage reconnect threshold (HR71). Installer-tier. Range 0.0–500.0 V.

    Part of the G98/G99/G100 reconnect band. Pass confirm=True after verifying grid-code compliance.
    Value is written as deci (int(voltage × 10)).
    """
    _grid_confirm("set_v_ac_low_limit_reconnect", confirm)
    val = _as_scaled_int(voltage, 10, "voltage")
    if not (0 <= val <= 5000):
        raise ValueError(f"v_ac_low_limit_reconnect must be 0.0–500.0 V, got {voltage}")
    return [WriteHoldingRegisterRequest(RegisterMap.V_AC_LOW_LIMIT_RECONNECT, val, installer=True)]

set_v_ac_low_limit_trip(voltage, *, confirm=False)

Set AC under-voltage trip threshold (HR63). Installer-tier. Range 0.0–500.0 V.

Part of the G98/G99/G100 trip band. Pass confirm=True after verifying grid-code compliance. Value is written as deci (int(voltage × 10)).

Source code in givenergy_modbus/client/commands.py
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
def set_v_ac_low_limit_trip(voltage: float, *, confirm: bool = False) -> list[WriteHoldingRegisterRequest]:
    """Set AC under-voltage trip threshold (HR63). Installer-tier. Range 0.0–500.0 V.

    Part of the G98/G99/G100 trip band. Pass confirm=True after verifying grid-code compliance.
    Value is written as deci (int(voltage × 10)).
    """
    _grid_confirm("set_v_ac_low_limit_trip", confirm)
    val = _as_scaled_int(voltage, 10, "voltage")
    if not (0 <= val <= 5000):
        raise ValueError(f"v_ac_low_limit_trip must be 0.0–500.0 V, got {voltage}")
    return [WriteHoldingRegisterRequest(RegisterMap.V_AC_LOW_LIMIT_TRIP, val, installer=True)]

three_phase_factory_reset(*, confirm=False)

Trigger three-phase factory reset without meter reset (HR1016). Irreversible.

Resets inverter configuration to factory defaults, excluding meter data. Pass confirm=True to proceed.

Source code in givenergy_modbus/client/commands.py
1359
1360
1361
1362
1363
1364
1365
1366
1367
def three_phase_factory_reset(*, confirm: bool = False) -> list[WriteHoldingRegisterRequest]:
    """Trigger three-phase factory reset without meter reset (HR1016). Irreversible.

    Resets inverter configuration to factory defaults, excluding meter data.
    Pass confirm=True to proceed.
    """
    if not confirm:
        raise ValueError("three_phase_factory_reset() is irreversible — pass confirm=True to proceed")
    return [WriteHoldingRegisterRequest(RegisterMap.THREE_PHASE_FACTORY_RESET, 1, installer=True)]

Model

Plant

Bases: GivEnergyBaseModel

Representation of a complete GivEnergy plant.

Source code in givenergy_modbus/model/plant.py
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
class Plant(GivEnergyBaseModel):
    """Representation of a complete GivEnergy plant."""

    model_config = ConfigDict(frozen=False, use_enum_values=True, arbitrary_types_allowed=True)

    register_caches: dict[int, RegisterCache] = {}
    capabilities: PlantCapabilities | None = None
    # The inverter serial from the response envelope, adopted only from the inverter's own
    # responses (see update()). This is the earliest-available inverter identity — populated at
    # detect() from the 0x11 read — and stays correct across the plant lifecycle, unlike
    # ``Plant.inverter.serial_number`` which is empty in the detect→first-refresh window for
    # AC/HYBRID_GEN1 (reads 0x31, not populated by detect) and reads the 0x32 battery cache on a
    # bare plant. A single unified accessor is tracked in #227. The dongle serial below has no
    # register source — the envelope is its only home.
    inverter_serial_number: str = ""
    data_adapter_serial_number: str = ""
    # Ingestion timestamps per committed register block, keyed by
    # (device_address, register_type_name, base_register) → last successful commit time (#65).
    # Maintained continuously by the network consumer's update() calls, so it captures both
    # solicited responses and the dongle's unsolicited fan-out. Consumers (and refresh()'s
    # skip-if-fresh path, #196) use block_age() to reason about freshness. Excluded from
    # model_dump(): it's ephemeral runtime bookkeeping, not part of the plant's dumpable state.
    register_block_updated_at: dict[tuple[int, str, int, int], datetime] = Field(default_factory=dict, exclude=True)
    # Per-block presence marker (slice 1 of #268): distinguishes "probed, confirmed absent" (False)
    # from "probed, confirmed present" (True) from "never probed / unknown" (missing key). Keyed
    # identically to register_block_updated_at; updated by mark_absent() (detect-helper rejections)
    # and set True alongside _stamp_block() (commit → present). invalidate_presence() drops the
    # marker and registers together, so a stale "present" never outlives a topology change. Excluded
    # from model_dump() — runtime instrumentation, not dumpable state.
    register_block_present: dict[tuple[int, str, int, int], bool] = Field(default_factory=dict, exclude=True)

    # Per-device comms-quality counters (#284): cumulative, monotonic event counts keyed by
    # device_address, for a consumer to surface a plant's "noise floor" without grepping logs.
    # Each counter mirrors a log event one-for-one (so logs and counters always agree) and only
    # ever increments — splice_held_count counts *hold events*, not currently-held banks. In-memory
    # and cumulative-since-construction (a reconnect re-instantiates the Plant; consumers track
    # reset-aware deltas). Excluded from model_dump() like the bookkeeping above — they're runtime
    # diagnostics, not dumpable plant state, but remain public gettable attributes.
    crc_failure_count: dict[int, int] = Field(default_factory=dict, exclude=True)
    splice_reject_count: dict[int, int] = Field(default_factory=dict, exclude=True)
    splice_held_count: dict[int, int] = Field(default_factory=dict, exclude=True)
    retry_count: dict[int, int] = Field(default_factory=dict, exclude=True)
    # Cold-start baseline holds (#289): a battery bank held back while the first frame after an
    # empty cache awaits a corroborating read (vs splice_held_count, which is corruption). A benign
    # "battery initialising / confirming baseline" signal — expected once per device at startup.
    cold_start_held_count: dict[int, int] = Field(default_factory=dict, exclude=True)

    # How long (seconds) to hold last-good for a disputed *constant* battery register (num_cells,
    # bms_firmware_version) before healing to a sustained new value (#286). Splice corruption that
    # flips a constant register reverts within minutes (the bank is held meanwhile, protecting the
    # co-corrupted physics like SOC), while a genuinely poisoned cold-start baseline — or a real
    # firmware upgrade — persists indefinitely and heals after this long. Consumer-tunable via
    # Client(splice_heal_seconds=…). Runtime config; excluded from model_dump.
    splice_heal_seconds: float = Field(default=900.0, exclude=True)

    # How long (seconds) a sustained *legitimate* >=2-physics battery step must persist, evolving
    # smoothly and in-range, before the terminal hard-reject heals to it (#299). ``None`` (default)
    # DISABLES the heal — the >=2-physics path stays terminal, zero behaviour change. A float opts
    # in (recommended 300, = STALE_BYPASS_SECONDS; never set below it). Separate from
    # splice_heal_seconds: that defends scalar poison which persists indefinitely (long window),
    # this a transient charge-knee surge (short window). The positive (heal-fires) path can't be
    # validated against the existing corpus, so it ships off by default until a real near-full-SOC
    # knee capture confirms the smoothness assumption. Consumer-tunable via
    # Client(splice_reject_heal_seconds=…). Runtime config; excluded from model_dump.
    splice_reject_heal_seconds: float | None = Field(default=None, exclude=True)

    # Content-staleness tracker — the duration substrate for frozen-BMS-cache detection (#91).
    # Keyed identically to register_block_updated_at; each value is (content_hash, unchanged_since)
    # where unchanged_since is the ingestion time of the FIRST commit in the current
    # byte-identical run. O(1) per block and survives arbitrarily long unchanged runs (a bounded
    # deque of hashes could not — it evicts the run's origin). Private/ephemeral, resets on
    # reconnect. Surfaced read-only via content_unchanged_seconds(); no freeze *verdict* is
    # exposed — see that method for why a threshold isn't yet derivable.
    _block_unchanged_since: dict = PrivateAttr(default_factory=dict)

    # Splice-guard escrow (#256): per battery device address, the single physics-singleton
    # transition currently held back pending confirmation — (tripping IR number, comparable
    # value held). A held bank never updates the cache, so the next poll re-compares against
    # the same last-good; the held value commits only if the device reads it again within
    # threshold (genuine step persists) rather than snapping back (transient splice reverts).
    # Ephemeral runtime state, not serialised. It lives on the Plant instance and persists across
    # connect()/close()/detect() — Client builds self.plant once (__init__) and reconnecting reuses
    # it, so this state is cleared only by constructing a new Client/Plant, never by a reconnect.
    _splice_escrow: dict[int, tuple[int, int]] = PrivateAttr(default_factory=dict)

    # Cold-start baseline confirmation (#289): per battery device address, the first full bank seen
    # against an empty cache, held pending a corroborating read — (the 60-value bank-relative frame,
    # ingestion time). A cold-start frame is not adopted until the next poll reads it the same (no
    # physics/immutable trip between the two), so a transient sub-bus splice (#256) — different
    # garbage each poll — never corroborates and never poisons the baseline. Most-recent-wins on a
    # disagreement. A persistently-identical scalar-immutable poison corroborates and is left to the
    # #286 heal; a corroborated temp-zero corruption cohort is refused outright (it would hard-reject
    # all healthy data, which the scalar heal can't recover). Ephemeral; not serialised; persists
    # across reconnect on the same Plant instance (see _splice_escrow).
    _splice_pending_baseline: dict[int, tuple[list[int], datetime]] = PrivateAttr(default_factory=dict)

    # Splice-guard observation clock (#256): per battery device address, the ingestion time of the
    # last full IR(60,60) bank the guard examined — accepted OR rejected. The stale-baseline bypass
    # keys off this, NOT the last accepted commit: a sustained corruption run (every poll rejected)
    # leaves the last-good commit ageing past the bypass window, but keeps arriving each poll, so
    # this clock stays ~one poll old and the bypass never fires. Only a genuine polling gap (real
    # outage, no banks at all) makes it stale and resets to cold-start adoption. Ephemeral; not
    # serialised; persists across reconnect on the same Plant instance (see _splice_escrow).
    _splice_last_seen: dict[int, datetime] = PrivateAttr(default_factory=dict)

    # Scalar-immutable poison-recovery streak (#281): per battery device address,
    # (signature, started_at, consecutive_count) where signature is the sorted tuple of
    # (IR number, incoming value) over the poll's scalar-immutable trips (IR97/IR98). Tracks a
    # *stable* disagreement on a constant scalar — a real value a healthy pack keeps reporting
    # after a poisoned cold-start baseline, or a genuine BMS firmware upgrade. A changing
    # signature (oscillating garbage) resets it, so corruption never accumulates a streak. Used
    # by both the coherent-physics escrow (confirm on the second identical read) and the
    # physics-drift backstop (force re-baseline once stable). Ephemeral; not serialised.
    _splice_immut_streak: dict[int, tuple[tuple[tuple[int, int], ...], datetime, int]] = PrivateAttr(
        default_factory=dict
    )

    # Sustained-step heal streak (#299): per battery device address, (prev_incoming_frame,
    # started_at, consecutive_count). When a >=2-physics reject is heal-eligible (voltage/cap-class
    # trips, in absolute range — see battery_splice.heal_eligible) and the heal is enabled, the bank
    # is held but the incoming frames are tracked here. The streak advances only while each new frame
    # is a SMOOTH continuation of the *previous incoming* frame (classify_transition between
    # consecutive incomings is clean), so a legitimate charge-knee surge — which drifts smoothly —
    # accumulates, while corruption (which reverts or jumps) restarts the count. After
    # SPLICE_REJECT_HEAL_POLLS smooth polls AND splice_reject_heal_seconds, the latest frame is
    # adopted as the new baseline. Ephemeral; not serialised.
    _splice_reject_streak: dict[int, tuple[list[int], datetime, int]] = PrivateAttr(default_factory=dict)

    # Per-device coalescing state for sustained same-signature reject bursts (#355):
    # device -> (trip signature = frozenset of (register, rule-name), burst start, rejection
    # count, last WARNING-emitted at). Onset warns with full detail, identical repeats drop to
    # DEBUG, an over-interval burst re-warns, and the first successful commit afterwards logs a
    # single clearing WARNING with the tally. Ephemeral; not serialised.
    _splice_reject_burst: dict[int, tuple[frozenset, datetime, int, datetime]] = PrivateAttr(default_factory=dict)

    # Direct-inverter register caches injected by add_direct_source() for multi-Client
    # reconciliation (#106 Phase 3). Stored separately from register_caches to avoid the
    # Modbus address collision (both EMS controller and direct inverter live at 0x11).
    # Not serialised — ephemeral runtime state rebuilt by the consumer on each run.
    _direct_source_caches: list[RegisterCache] = PrivateAttr(default_factory=list)

    def model_post_init(self, __context: Any) -> None:
        """Ensure a default register cache is always present."""
        if not self.register_caches:
            self.register_caches = {0x32: RegisterCache()}

    @classmethod
    def from_caches(
        cls,
        register_caches: dict[int, RegisterCache],
        prior: PlantCapabilities | None = None,
        *,
        inverter_serial_number: str = "",
        data_adapter_serial_number: str = "",
    ) -> "Plant":
        """Build a fully-typed Plant from an injected register-cache set, with no live client (#268).

        Capabilities are derived from the caches (no wire I/O) via :func:`_derive_capabilities`, so
        every typed view (``.inverter``/``.batteries``/``.meters``/``.hv_stacks``/``.ems``/
        ``.inverters``) works exactly as on a live-detected plant. The recipe for a capture dump is
        ``Plant.from_caches(plant_from_capture(path).register_caches)``.

        Raises :class:`CommunicationError` if HR(0) is absent at device 0x11.
        """
        plant = cls(
            register_caches=register_caches,
            inverter_serial_number=inverter_serial_number,
            data_adapter_serial_number=data_adapter_serial_number,
        )
        plant.capabilities = _derive_capabilities(register_caches, prior)
        return plant

    # Authoritative device-address map (GivEnergy installer app v1.154.3 `device_address_map`),
    # for orientation — not every band is modelled/routed below:
    #   0x11        inverter / EMS controller    (0x31 = legacy pre-#189 facade)
    #   0x21        EMS firmware-version device
    #   0x01-0x08   energy meters
    #   0x20+       BMS cluster (per clusterId)  — UNMODELLED (see #329)
    #   0x23-0x2A   PCS-managed inverters
    #   0x31+/0x32-0x37  LV battery packs
    #   0x50-0x6F   HV BMU (per module)
    #   0x70-0x8F   HV BCU (per stack)
    #   0x90+       HV BAMS                       — UNMODELLED
    # See docs/reference/registers/installer-app-reference.md.
    def _getter_for_device_address(self, device_address: int) -> type[RegisterGetter] | None:
        """Return the RegisterGetter class appropriate for a given device address.

        With capabilities the inverter lives at ``capabilities.inverter_address``
        (0x11 since #189; 0x31 may persist from pre-#189 state and the AC/
        HYBRID_GEN1 hardware facade still answers there) and 0x32 is LV battery
        pack #1. Without capabilities the separation is unconditional (#352): only
        0x11/0x31 are the inverter and 0x32–0x37 are LV battery packs. This means a
        0x32 read issued before detect() sets capabilities still routes through the
        battery splice guard rather than the inverter getter — closing the #350
        seeding window. (The pre-#189 layout where 0x32 carried inverter data is no
        longer aliased here, consistent with a persisted 0x32 surfacing as a
        PlantTopologyMismatch; live use always has capabilities after detect().)
        """
        if self.capabilities is not None:
            if device_address == self.capabilities.inverter_address:
                return SinglePhaseInverterRegisterGetter
            # After the inverter check so a (theoretical) pre-#189 persisted
            # inverter_address=0x31 still routes to the inverter getter.
            if device_address == self.capabilities.lv_bcu_address:
                return LvBcuRegisterGetter
            if device_address in self.capabilities.hv_bmu_addresses:
                return BmuRegisterGetter
            if 0x32 <= device_address <= 0x37:
                return BatteryRegisterGetter
        else:
            if device_address in (0x11, 0x31):
                return SinglePhaseInverterRegisterGetter
            if 0x32 <= device_address <= 0x37:
                return BatteryRegisterGetter
        if 0x01 <= device_address <= 0x08:
            return MeterRegisterGetter
        if 0x70 <= device_address <= 0x8F:
            return BcuRegisterGetter
        return None

    def redact(self) -> "Plant":
        """Return a share-safe copy: every register cache redacted and the header serials cleared.

        ``redact_serials()`` only covers the register caches; ``inverter_serial_number`` and
        ``data_adapter_serial_number`` live on the Plant itself (populated from the PDU envelope),
        so a dumped Plant still leaks both unless they're redacted here too. The original is left
        untouched (#212/#214 share-safe-export guarantee).
        """
        from givenergy_modbus.model.register import Converter

        # Fail closed: redact_serial_strict blanks any unrecognised identifier rather than leaking
        # it verbatim (redact_serial is fail-open). register_block_updated_at is copied so the
        # redacted snapshot stays independent of later updates to the original.
        return self.model_copy(
            update={
                "register_caches": {addr: cache.redact_serials() for addr, cache in self.register_caches.items()},
                "inverter_serial_number": Converter.redact_serial_strict(self.inverter_serial_number),
                "data_adapter_serial_number": Converter.redact_serial_strict(self.data_adapter_serial_number),
                "register_block_updated_at": dict(self.register_block_updated_at),
                "register_block_present": dict(self.register_block_present),
            }
        )

    @staticmethod
    def _bump(counter: dict[int, int], device_address: int) -> None:
        """Increment a per-device comms-quality counter (#284)."""
        counter[device_address] = counter.get(device_address, 0) + 1

    def record_retry(self, device_address: int) -> None:
        """Record a consumed read retry for a device (#284); called by the Client per retry."""
        self._bump(self.retry_count, device_address)

    def update(self, pdu: ClientIncomingMessage, *, received_at: datetime | None = None):
        """Update the Plant state from a PDU message.

        ``received_at`` overrides the ingestion timestamp recorded for a committed
        register block (see ``register_block_updated_at`` / #65); it defaults to the
        current UTC time and is provided mainly for deterministic testing and replay.
        """
        if not isinstance(pdu, TransparentResponse):
            _logger.debug(f"Ignoring non-Transparent response {pdu}")
            return
        if isinstance(pdu, NullResponse):
            _logger.debug(f"Ignoring Null response {pdu}")
            return
        if pdu.error:
            _logger.debug(f"Ignoring error response {pdu}")
            return
        _logger.debug(f"Handling {pdu}")

        # Reject CRC-failed frames before ANY Plant state is mutated. The CRC spans the device
        # address and serial fields in the envelope, so those are untrusted on exactly the frames
        # that fail here — a corrupt 0x11 response must not clobber the stable inverter identity.
        if getattr(pdu, "crc_failed", False) and not getattr(pdu, "lenient_crc_commit", False):
            self._bump(self.crc_failure_count, pdu.device_address)
            _logger.warning(
                "Skipping CRC-failed response from 0x%02x (base=%d) — no Plant state updated",
                pdu.device_address,
                getattr(pdu, "base_register", 0),
            )
            return

        # Store responses under their true wire device address. The old 0x11/0x00 → 0x32
        # fold was a courtesy to GivEnergy's cloud, not an inverter requirement: querying
        # 0x11 was relayed upstream by the dongle, and sub-5-minute polling there disturbed
        # their 5-minute dashboards — 0x32 was the side-door that left the cloud product
        # alone (0x00 was app traffic folded in on an assumption). Both rationales are now
        # moot (cloud is premium-only). The fold masked that 0x11 is the inverter's
        # canonical address and 0x32 is LV battery pack #1 (issue #119); detect() now
        # resolves the inverter address per model and reads/caches consistently at it.
        device_address = pdu.device_address

        if device_address not in self.register_caches:
            _logger.debug(f"First time encountering device address 0x{device_address:02x}")
            self.register_caches[device_address] = RegisterCache()

        # The TCP dongle's serial is identical on every response regardless of the addressed
        # downstream device (verified across the AIO capture: meters, inverter, modules, BCU and
        # BMS all carry the same data_adapter serial), so adopt it from any accepted PDU.
        self.data_adapter_serial_number = pdu.data_adapter_serial_number

        # inverter_serial_number, by contrast, is the *addressed device's* serial in the envelope:
        # battery (0x32-0x37), BCU/BMS (0x70+/0xA0) and AIO battery-module (0x50-0x53) responses
        # carry their own, so adopting it from every PDU let whichever device was polled last
        # clobber the real inverter serial — merging the AIO inverter HA device into a battery
        # module downstream (givenergy-hass#95). The inverter is canonically addressed at 0x11
        # (#189), with 0x31 a hardware facade on AC/HYBRID_GEN1 that other bus consumers may
        # still poll, so gate on that pair — it excludes peripherals and the legacy 0x32
        # (battery pack #1) a pre-#119 persisted capability may still carry until detect()
        # self-heals.
        if device_address in (0x11, 0x31):
            self.inverter_serial_number = pdu.inverter_serial_number

        if isinstance(pdu, ReadHoldingRegistersResponse):
            incoming = {HR(k): v for k, v in pdu.to_dict().items()}
            if self._commit_bank(device_address, incoming, pdu.register_count, received_at=received_at):
                self._stamp_block(device_address, "HR", pdu.base_register, pdu.register_count, received_at)
                self.register_block_present[(device_address, "HR", pdu.base_register, pdu.register_count)] = True
                self._track_content_change(
                    device_address, "HR", pdu.base_register, pdu.register_count, incoming, received_at
                )
        elif isinstance(pdu, ReadInputRegistersResponse):
            if pdu.is_suspicious():
                # Pattern A dongle-side substitution from #78 — known fingerprint of 16 fixed
                # constants. is_suspicious() logs at debug when it fires.
                return
            incoming = {IR(k): v for k, v in pdu.to_dict().items()}  # type: ignore[misc]
            if self._commit_bank(device_address, incoming, pdu.register_count, received_at=received_at):
                self._stamp_block(device_address, "IR", pdu.base_register, pdu.register_count, received_at)
                self.register_block_present[(device_address, "IR", pdu.base_register, pdu.register_count)] = True
                self._track_content_change(
                    device_address, "IR", pdu.base_register, pdu.register_count, incoming, received_at
                )
        elif isinstance(pdu, WriteHoldingRegisterResponse):
            if pdu.register == 0:
                _logger.warning(f"Ignoring, likely corrupt: {pdu}")
            else:
                # Writes target the inverter and the echo comes back on the write address
                # (0x11), and the model reads caps.inverter_address. Since #189 unified
                # addressing on 0x11 the two normally coincide, but a pre-#189 persisted
                # capability may still say 0x31 (the AC/HYBRID_GEN1 facade), so keep routing
                # the echo to where reads land — otherwise plant.inverter won't reflect the
                # write until the next load_config(), and refresh() (IR-only) never will. The
                # cache may not exist yet if the write precedes the first read there.
                target = self.capabilities.inverter_address if self.capabilities is not None else device_address
                self.register_caches.setdefault(target, RegisterCache()).update({HR(pdu.register): pdu.value})

    def _commit_bank(
        self,
        device_address: int,
        incoming: dict,
        register_count: int = 60,
        *,
        received_at: datetime | None = None,
    ) -> bool:
        """Validate incoming register bank against bounds and commit if clean.

        Returns True if the bank was committed to the cache, False if it was discarded —
        so the caller only records an ingestion timestamp (#65) for banks that actually
        landed.

        ``register_count`` is the declared size of the PDU response (typically 60 for
        standard IR/HR blocks). It is used by the Pattern B guard: rejection only applies
        to full blocks (>= 60 registers), where a simultaneous all-zero read is genuinely
        suspicious. A short read (e.g. a single power register legitimately settling to
        zero) must not be blocked.

        ``received_at`` is the PDU ingestion timestamp, threaded to the splice guard so it
        can measure baseline staleness consistently with the rest of the stamping logic.
        """
        cache = self.register_caches[device_address]
        # Pattern B (#78/#147/#199, tracked in #206): a bank that previously held non-zero data and
        # now reads entirely zero is a block-level dropout (an empty page served during a
        # transition), not real data — reject it and keep last-good. Contextual, to resolve the
        # dropout-vs-absent ambiguity that made #147 hard to action: was-non-zero & now-all-zero =
        # dropout -> reject; always-zero (absent / first read) -> fall through to the existing
        # serial-coherence / is_valid() handling that already treats all-zero as device-absence.
        # Staleness is free: a rejected bank records no #65 timestamp, so block_age() keeps growing.
        # Gated on register_count >= 60: short reads (fan-out of a single-register query returning
        # zero) are legitimate and must not be blocked here.
        if (
            register_count >= 60
            and incoming
            and all(v == 0 for v in incoming.values())
            and any(cache.get(k) for k in incoming)
        ):
            sample = next(iter(incoming))
            # WARNING (not debug): we have no on-wire capture of this event — surfacing it turns every
            # deployment (and the maintainer's soak run) into an evidence collector. A silent no-op on
            # healthy systems, since it only fires when a present device's bank drops to all-zero.
            _logger.warning(
                "Rejected all-zero %s bank (base %d) for device 0x%02x over non-zero cache — likely a "
                "Pattern B block dropout (#206); keeping last-good. Please report if seen.",
                type(sample).__name__,
                min(r._idx for r in incoming),
                device_address,
            )
            return False
        # Internally-impossible battery frame (#350): a partial sub-bus splice (cell block zeroed,
        # scalars intact) is physically impossible. Refuse it at the door — getter-agnostic, because a
        # 0x32 read during a capabilities-None window (e.g. a fresh Plant mid-reconnect) is routed to the
        # inverter getter and so bypasses the battery splice guard below; without this it seeds a poisoned
        # baseline the guard then defends once capabilities resolve 0x32 to a battery. Gated to the LV
        # battery range, where IR(60-75) are cell voltages on every model.
        if (
            register_count >= 60
            and 0x32 <= device_address <= 0x37
            and is_internally_impossible(
                [incoming.get(IR(BANK_BASE + i), 0) for i in range(60)],
                {i for i in range(60) if IR(BANK_BASE + i) in incoming},
            )
        ):
            self._bump(self.splice_reject_count, device_address)
            _logger.warning(
                "Rejected internally-impossible battery bank for device 0x%02x — all cells 0 with "
                "firmware/capacity present; refusing to cache it. Please report if seen.",
                device_address,
            )
            return False
        getter_cls = self._getter_for_device_address(device_address)
        if getter_cls is not None:
            if not getter_cls.is_coherent(incoming, self.register_caches[device_address]):
                # Common on a shared bus: other clients (cloud, mobile app, GivTCP) poll
                # empty slots beyond the user's actual hardware, and we see the responses
                # go by. The discard is correct; logging at WARNING was actionable noise.
                _logger.debug("Discarding register bank with invalid serial for device 0x%02x", device_address)
                return False
            violations = getter_cls.validate_bank(incoming, self.register_caches[device_address])
            if violations:
                _logger.debug(
                    "Bounds violations in register bank for device 0x%02x: %s",
                    device_address,
                    violations,
                )
                # TODO(enforcement): add `return False` here to discard the entire bank on any
                # violation. When that happens, also raise this back to WARNING — at that point
                # it has user-visible consequences (a poll cycle's data is dropped).
        # Battery sub-bus splice guard (#256): valid-CRC, in-bounds, valid-serial garbage that
        # all the checks above wave through. Runs last so the existing serial gate owns serial
        # corruption (quietly) and the bounds pass runs first; it must see last-good, so it sits
        # before the update below. Gated to battery banks via the getter identity.
        if getter_cls is BatteryRegisterGetter:
            if not self._splice_guard(device_address, incoming, register_count, now=received_at):
                return False
            # A committing FULL battery bank closes out any coalesced reject burst (#355).
            # Short reads (register_count < 60) pass the guard unevaluated — mirroring the
            # guard's own gate — so a count=1 fan-out commit between corrupt full banks
            # must not pop the burst (it would log a misleading 'cleared' and re-spam
            # fresh onsets).
            if register_count >= 60:
                commit_ts = received_at if received_at is not None else datetime.now(UTC)
                if commit_ts.tzinfo is None:
                    commit_ts = commit_ts.replace(tzinfo=UTC)
                self._splice_burst_note_commit(device_address, commit_ts)
        self.register_caches[device_address].update(incoming)
        return True

    def _splice_guard(
        self, device_address: int, incoming: dict, register_count: int, now: datetime | None = None
    ) -> bool:
        """Reject (or escrow) a battery bank corrupted by a valid-CRC BMS sub-bus splice (#256).

        Returns True to allow the commit, False to hold last-good. Compares the incoming bank
        against the cached last-good using per-register-class physics thresholds
        (:mod:`givenergy_modbus.model.battery_splice`): a change to a constant register or
        >=2 physically-impossible per-poll deltas is corruption and is rejected outright; a
        lone impossible delta is escrowed — held one poll and committed only if the next poll
        reads the same value again (a genuine step persists; every observed splice reverts).

        Only full battery banks are guarded: a short read can't be physics-classified, a
        device with no prior committed bank (cold start) has nothing to compare against, and
        a gap of more than ``STALE_BYPASS_SECONDS`` since the last *observed* full bank — a
        genuine polling outage, not a rejection streak — is too stale for per-poll thresholds
        to apply (legitimate multi-field drift over the gap would trip them) — all three fall
        through as a no-op commit so the cache self-heals without manual recovery. The one
        exception is the temp-zero corruption cohort, which is held (never adopted) on every
        path, not just cold start (#294).
        """
        now_ts = now if now is not None else datetime.now(UTC)
        if now_ts.tzinfo is None:
            now_ts = now_ts.replace(tzinfo=UTC)

        cache = self.register_caches[device_address]
        prev = [0] * 60
        new = [0] * 60
        present: set[int] = set()
        incoming_present: set[int] = set()
        for i in range(60):
            reg = IR(BANK_BASE + i)
            cached = cache.get(reg)
            incoming_val = incoming.get(reg)
            prev[i] = cached if cached is not None else 0
            new[i] = incoming_val if incoming_val is not None else prev[i]
            if incoming_val is not None:
                incoming_present.add(i)
                if cached is not None:
                    present.add(i)

        # Short read: a partial bank can't be physics-classified, so it commits directly — EXCEPT the
        # temp-zero corruption cohort (>=2 cell-mass temps at 0), which would seed an unrecoverable
        # poisoned baseline (#294 — the same hole #289 closed for cold start). A short read is not a
        # full observation, so this returns before the _splice_last_seen update below (it must not
        # advance the stale-bypass clock).
        if register_count < 60:
            if is_corruption_cohort(new, incoming_present):
                self._bump(self.splice_reject_count, device_address)
                _logger.warning(
                    "Rejected short battery bank for device 0x%02x — temp-zero corruption cohort; "
                    "keeping last-good. Please report if seen.",
                    device_address,
                )
                return False
            return True

        # Record this observation up front — a rejected bank is still an observation, so the gap
        # computed below measures time since we last *saw* a full bank, not since we last accepted
        # one. This is what separates a real outage from a sustained corruption run (see below).
        prev_seen = self._splice_last_seen.get(device_address)
        self._splice_last_seen[device_address] = now_ts

        if not present:
            # Cold start: no last-good to compare against. Don't adopt the first frame blindly — a
            # transient splice would poison the baseline (#289). Hold it until a second poll
            # corroborates it (incoming_present, not present, since the cache is empty here).
            return self._confirm_cold_start_baseline(device_address, list(new), incoming_present, now_ts)

        # Internally-impossible last-good (#350): an out-of-band partial splice can seed a baseline
        # with every cell 0 but firmware/capacity intact — physically impossible, and once cached
        # every healthy read trips >=2 physics deltas (0->~3300 mV) and is rejected forever (no
        # existing heal covers a cell-voltage-zero baseline). Don't defend it: route the live read
        # through cold-start corroboration to re-seed. Checked BEFORE the pending-baseline pop below
        # so the held first healthy frame survives to be corroborated by the next poll.
        cache_present = {i for i in range(60) if cache.get(IR(BANK_BASE + i)) is not None}
        if is_internally_impossible(prev, cache_present) and not is_internally_impossible(new, incoming_present):
            _logger.warning(
                "Battery bank for device 0x%02x: last-good baseline is internally impossible (all "
                "cells 0 with firmware/capacity present) — re-seeding from corroborated live reads. "
                "Please report if seen.",
                device_address,
            )
            return self._confirm_cold_start_baseline(device_address, list(new), incoming_present, now_ts)

        # Past cold start — a real baseline exists, so any pending first frame is moot. Clear it so a
        # short read that seeded the cache mid-confirmation can't leave an orphan around.
        self._splice_pending_baseline.pop(device_address, None)

        # Stale-observation bypass: after a genuine polling gap the per-poll thresholds don't hold
        # (legitimate SOC/temp/cap drift over the gap would exceed them), and rejected banks never
        # advance the cache, so without recovery the guard would pin the cache to the stale baseline
        # forever after a network outage. Crucially this keys off the last *observation*, not the
        # last accepted commit: a sustained corruption run (e.g. a multi-poll temp-zero stream)
        # keeps arriving and being rejected each poll, ageing the last *commit* past the window —
        # but prev_seen stays ~one poll old, so this does NOT fire and the corruption stays rejected.
        if prev_seen is not None and (now_ts - prev_seen).total_seconds() > STALE_BYPASS_SECONDS:
            if is_corruption_cohort(new, incoming_present):
                # Don't adopt the corruption cohort even post-gap (#294). Hold last-good and keep the
                # bypass armed for the next healthy frame: restore the observation clock to prev_seen
                # so the gap is still seen next poll — otherwise a healthy recovery frame would be
                # rejected against the stale baseline, re-pinning the very thing the bypass avoids.
                self._splice_last_seen[device_address] = prev_seen
                self._bump(self.splice_reject_count, device_address)
                _logger.warning(
                    "Battery bank for device 0x%02x: stale-baseline bypass blocked — temp-zero "
                    "corruption cohort post-gap; holding last-good for a healthy read. Please report if seen.",
                    device_address,
                )
                return False
            self._splice_escrow.pop(device_address, None)
            self._splice_immut_streak.pop(device_address, None)
            self._splice_reject_streak.pop(device_address, None)
            _logger.info(
                "Battery bank for device 0x%02x: %.0f s since the last observed bank — splice guard "
                "bypassed (stale baseline); adopting as new baseline.",
                device_address,
                (now_ts - prev_seen).total_seconds(),
            )
            return True

        phys, immut = classify_transition(prev, new, present)

        # Split immutable trips by recoverability (#281): the serial block (IR110-114) genuinely
        # can't change, but the scalar immutables (num_cells IR97, bms_firmware_version IR98) can be
        # poisoned by a corrupt cold-start frame — or change legitimately on a BMS firmware upgrade —
        # and a healthy pack reports them *stably*, so a sustained stable disagreement is recoverable.
        serial_immut = [t for t in immut if t[0] in IMMUTABLE_SERIAL]
        scalar_immut = [t for t in immut if t[0] in IMMUTABLE_SCALAR]

        # Hard reject: a serial-block change (wrong pack / re-address) never self-adopts.
        if serial_immut:
            self._splice_escrow.pop(device_address, None)
            self._splice_immut_streak.pop(device_address, None)
            self._splice_reject_streak.pop(device_address, None)
            self._bump(self.splice_reject_count, device_address)
            warn_now, suffix = self._splice_burst_note_reject(device_address, serial_immut + phys, now_ts)
            (_logger.warning if warn_now else _logger.debug)(
                "Rejected battery bank for device 0x%02x — sub-bus splice (serial-block change); "
                "keeping last-good. Trips: %s%s. Please report if seen.",
                device_address,
                self._format_splice_trips(serial_immut + phys),
                suffix,
            )
            return False

        # >=2 physics deltas with no recoverable scalar immutable: corruption (the temp-zero cohort,
        # incl. the IR103/104 t_max/t_min pair) OR — rarely — a legitimate sustained step (the
        # near-full-SOC charge knee, #299). When the heal is enabled (opt-in) AND the bank is
        # heal-eligible (every trip a voltage/cap-class surge in absolute range, so it can't be any
        # corruption shape the corpus contains), hand off to the smooth-streak heal: hold now, adopt
        # only after a sustained smooth in-range run. Otherwise terminal reject, as before.
        if len(phys) >= 2 and not scalar_immut:
            self._splice_escrow.pop(device_address, None)
            self._splice_immut_streak.pop(device_address, None)
            if self.splice_reject_heal_seconds is not None and heal_eligible(phys):
                return self._handle_reject_streak(device_address, new, present, phys, now_ts)
            self._splice_reject_streak.pop(device_address, None)
            self._bump(self.splice_reject_count, device_address)
            warn_now, suffix = self._splice_burst_note_reject(device_address, phys, now_ts)
            # "Please report if seen" solicits UNcharacterised shapes only (#355): the corpus's
            # sole corruption signature is the temperature family (cell-mass IR76-79/103-104 +
            # mosfet IR81 — the temp-zero cohort and its partial variants), characterised to
            # death, so reporting it adds nothing. Any trip OUTSIDE the temp family is a novel
            # shape — exactly the evidence the corpus model and the #299 heal question still want.
            _TEMP_FAMILY = ("cell_temp_deci", "mosfet_temp_deci")
            plea = "" if all(name in _TEMP_FAMILY for _ir, name, _o, _n in phys) else " Please report if seen."
            (_logger.warning if warn_now else _logger.debug)(
                "Rejected battery bank for device 0x%02x — sub-bus splice (>=2 physics-impossible "
                "deltas); keeping last-good. Trips: %s%s.%s",
                device_address,
                self._format_splice_trips(phys),
                suffix,
                plea,
            )
            return False

        # Scalar-immutable change (no serial trip): escrow + re-baseline rather than reject (#281).
        # A scalar disagreement isn't the sustained-step path, so any heal streak is interrupted.
        if scalar_immut:
            self._splice_reject_streak.pop(device_address, None)
            return self._handle_scalar_immutable(device_address, scalar_immut, phys, now_ts)

        # No scalar-immutable trip this poll: any in-progress scalar disagreement is interrupted, so
        # the backstop streak resets here — it requires an *uninterrupted* stable signature. Covers
        # both the physics-singleton path below and the clean-transition path (#281 review). The
        # sustained-step heal streak (#299) likewise needs an uninterrupted >=2 run, so a frame that
        # falls to <=1 trip (singleton/clean) breaks and resets it here too.
        self._splice_immut_streak.pop(device_address, None)
        self._splice_reject_streak.pop(device_address, None)

        if len(phys) == 1:
            ir_no, name, _old, new_val = phys[0]
            held = self._splice_escrow.get(device_address)
            if held is not None and held[0] == ir_no and abs(new_val - held[1]) <= THRESHOLD_BY_CLASS[name]:
                # The held value read the same again this poll — a genuine step that persists,
                # not a transient splice (which reverts) — so commit the now-confirmed bank.
                self._splice_escrow.pop(device_address, None)
                _logger.info(
                    "Battery bank for device 0x%02x: escrowed step at IR(%d) confirmed on re-read; committing.",
                    device_address,
                    ir_no,
                )
                return True
            self._splice_escrow[device_address] = (ir_no, new_val)
            self._bump(self.splice_held_count, device_address)
            # A lone out-of-threshold delta is the self-healing escrow path: held one poll, then
            # committed if it persists (genuine step) or dropped if it reverts (transient). It is
            # NOT confirmed corruption — the >=2/immutable REJECT above is — so it logs at INFO to
            # avoid alarming on legitimate load-step sag / recalibration (#256, hass#186).
            _logger.info(
                "Battery bank for device 0x%02x: single out-of-threshold delta (%s) held one poll pending "
                "confirmation; serving last-good meanwhile.",
                device_address,
                self._format_splice_trips(phys),
            )
            return False

        # Clean transition (no trips): a previously-held step that snapped back lands here, so
        # drop any escrow and commit. Log the reversion so the held->resolved story is visible at
        # one level (the hold is INFO too). The scalar-immut streak was already cleared above.
        reverted = self._splice_escrow.pop(device_address, None)
        if reverted is not None:
            _logger.info(
                "Battery bank for device 0x%02x: previously-held delta at IR(%d) reverted on re-read "
                "(transient); committing clean bank.",
                device_address,
                reverted[0],
            )
        return True

    def _confirm_cold_start_baseline(
        self, device_address: int, frame: list[int], incoming_present: set[int], now_ts: datetime
    ) -> bool:
        """Hold a device's first post-empty-cache battery bank until a second poll corroborates it (#289).

        With no last-good to compare against, adopting the first frame blindly lets a transient sub-bus
        splice (#256) poison the baseline — the very state #281/#286 then spend the heal window
        recovering from. Instead the first frame is held (cache untouched, the device serves "unknown")
        and adopted only once the next poll reads it the same — ``classify_transition`` finds no physics
        or immutable trip between the two. A transient splice reads different garbage each poll, so it
        never corroborates; on a disagreement the most-recent frame becomes the new candidate (so a
        splice in the *first* frame is recovered from).

        Two exceptions to "corroboration adopts": a frame in the temp-zero corruption cohort
        (:func:`is_corruption_cohort`) is refused even when corroborated — adopting it would
        hard-reject every healthy frame forever, an unrecoverable physics-only poison. A
        persistently-identical *scalar*-immutable poison (IR97/IR98) does corroborate and is adopted,
        because recovering that one is the #286 heal's job, not this guard's.

        Returns True to adopt (corroborated), False to keep holding last-good ("unknown").
        """
        pending = self._splice_pending_baseline.get(device_address)
        # A pending older than the stale-bypass window straddles a genuine polling gap — don't
        # corroborate across it; treat the incoming frame as a fresh first read.
        if pending is not None and (now_ts - pending[1]).total_seconds() > STALE_BYPASS_SECONDS:
            pending = None
        if pending is not None:
            phys, immut = classify_transition(pending[0], frame, incoming_present)
            if not phys and not immut and is_corruption_cohort(frame, incoming_present):
                # Corroborated, but the frame IS the temp-zero corruption signature (#256/#289 review).
                # Baselining it would be unrecoverable: every later healthy frame then trips >=2 physics
                # and is hard-rejected forever, and the #286 heal only recovers scalar-immutable poison.
                # Keep holding (cache untouched) — a healthy corroborated pair will baseline instead.
                self._bump(self.cold_start_held_count, device_address)
                _logger.warning(
                    "Battery bank for device 0x%02x: cold-start frame corroborated but is the temp-zero "
                    "corruption cohort; refusing to baseline it, holding for a healthy read. Please report if seen.",
                    device_address,
                )
                return False
            if not phys and not immut:
                self._splice_pending_baseline.pop(device_address, None)
                _logger.info(
                    "Battery bank for device 0x%02x: cold-start baseline corroborated on re-read; committing.",
                    device_address,
                )
                return True
            reason = f"cold-start reads disagree ({self._format_splice_trips(phys + immut)})"
        else:
            reason = "first cold-start frame"
        # Hold (or re-hold) the most-recent frame as the pending baseline and wait for corroboration.
        self._splice_pending_baseline[device_address] = (frame, now_ts)
        self._bump(self.cold_start_held_count, device_address)
        _logger.info(
            "Battery bank for device 0x%02x: %s — holding pending a corroborating read; serving unknown meanwhile.",
            device_address,
            reason,
        )
        return False

    def _handle_scalar_immutable(
        self,
        device_address: int,
        scalar_immut: list[tuple[int, str, int, int]],
        phys: list[tuple[int, str, int, int]],
        now_ts: datetime,
    ) -> bool:
        """Hold a scalar-immutable (IR97/IR98) disagreement; heal only after long insistence (#286).

        Supersedes the #281 fast paths (coherent 2-read / 6-poll backstop), which adopted ongoing
        corruption that happened to stay stable for a few polls.

        A constant register (num_cells, bms_firmware_version) changing is treated as corruption
        until proven otherwise: ongoing splice corruption reverts within minutes, while a genuinely
        poisoned cold-start baseline — or a real firmware upgrade — persists indefinitely. So every
        poll holds the *whole* bank at last-good (return False), which also protects any
        co-corrupted physics (e.g. SOC) riding in the same bank; the new value is adopted only once
        the SAME incoming signature has been insisted upon, uninterrupted, for at least
        ``SCALAR_IMMUT_HEAL_POLLS`` polls AND ``self.splice_heal_seconds`` seconds. A changing
        signature or a clean (baseline-matching) poll resets the streak, so corruption — which
        reverts well inside the heal window — never self-heals into the cache.

        Always a *recoverable hold* (INFO + ``splice_held_count``), never a WARNING reject. The hold
        deliberately does not re-stamp the block's ingestion time, so ``block_age`` grows and a
        consumer can see the data is cached/stale and decide what to do. Returns True to adopt
        (heal), False to hold.
        """
        sig = tuple(sorted((ir_no, new_val) for ir_no, _name, _old, new_val in scalar_immut))
        streak = self._splice_immut_streak.get(device_address)

        if streak is not None and streak[0] == sig:
            started, count = streak[1], streak[2] + 1
            elapsed = (now_ts - started).total_seconds()
            if count >= SCALAR_IMMUT_HEAL_POLLS and elapsed >= self.splice_heal_seconds:
                self._splice_escrow.pop(device_address, None)
                self._splice_immut_streak.pop(device_address, None)
                _logger.info(
                    "Battery bank for device 0x%02x: constant register(s) %s held the same value for "
                    "%d consecutive polls (%.0f s) — adopting as new baseline (poisoned baseline or "
                    "firmware change).",
                    device_address,
                    self._fmt_scalar_sig(scalar_immut),
                    count,
                    elapsed,
                )
                return True
            self._splice_immut_streak[device_address] = (sig, started, count)
            new_streak = False
        else:
            # New device, or the signature changed (oscillating garbage): (re)start the streak.
            self._splice_immut_streak[device_address] = (sig, now_ts, 1)
            new_streak = True

        self._splice_escrow.pop(device_address, None)
        self._bump(self.splice_held_count, device_address)
        if new_streak:
            # Log once per insistence run (not every poll) to avoid spamming a long hold; the
            # per-poll signal is splice_held_count + the growing block_age.
            _logger.info(
                "Battery bank for device 0x%02x: constant register(s) %s disagree with baseline — "
                "holding last-good, will adopt only after %.0f s of sustained agreement. Trips: %s.",
                device_address,
                self._fmt_scalar_sig(scalar_immut),
                self.splice_heal_seconds,
                self._format_splice_trips(scalar_immut + phys),
            )
        return False

    def _handle_reject_streak(
        self,
        device_address: int,
        new: list[int],
        present: set[int] | None,
        phys: list[tuple[int, str, int, int]],
        now_ts: datetime,
    ) -> bool:
        """Hold a heal-eligible >=2-physics step; adopt after a sustained smooth in-range run (#299).

        Reached only when the heal is enabled (``splice_reject_heal_seconds`` set) AND the bank is
        heal-eligible (:func:`~givenergy_modbus.model.battery_splice.heal_eligible`: every trip a
        voltage/capacity-class surge in absolute range, so it can't be any corruption shape the
        corpus contains). The terminal >=2-physics reject otherwise has no recovery, so a legitimate
        sustained step — the near-full-SOC charge knee, which trips >=2 voltage rules against the
        frozen baseline — freezes telemetry until it settles (#299).

        The discriminator is *smoothness across consecutive incoming frames*, NOT the value-equality
        the #286 scalar heal uses — a real surge drifts (3644->3631->3622...), so its signature never
        stabilises. A legitimate surge evolves smoothly poll-to-poll (``classify_transition`` between
        consecutive incomings is clean); corruption reverts or jumps. So the streak advances only on
        a smooth continuation and adopts the latest frame after ``SPLICE_REJECT_HEAL_POLLS`` polls
        AND ``splice_reject_heal_seconds``. Until then it's a recoverable hold (INFO +
        ``splice_held_count``), serving last-good with a growing ``block_age``. The frozen baseline is
        used only to compute ``phys`` (the eligibility gate, by the caller), never for the streak.
        Returns True to adopt (heal), False to hold.
        """
        heal_seconds = self.splice_reject_heal_seconds
        assert heal_seconds is not None  # the caller only delegates here when the heal is enabled
        streak = self._splice_reject_streak.get(device_address)
        if streak is not None:
            prev_incoming, started = streak[0], streak[1]
            step_phys, step_immut = classify_transition(prev_incoming, new, present)
            if not step_phys and not step_immut:
                # Smooth continuation of the previous incoming frame — advance the streak.
                count = streak[2] + 1
                elapsed = (now_ts - started).total_seconds()
                if count >= SPLICE_REJECT_HEAL_POLLS and elapsed >= heal_seconds:
                    self._splice_reject_streak.pop(device_address, None)
                    _logger.info(
                        "Battery bank for device 0x%02x: sustained step evolved smoothly and in-range "
                        "for %d consecutive polls (%.0f s) — adopting as new baseline (legitimate "
                        "surge, e.g. near-full-SOC charge knee). Trips: %s.",
                        device_address,
                        count,
                        elapsed,
                        self._format_splice_trips(phys),
                    )
                    return True
                self._splice_reject_streak[device_address] = (new, started, count)
                new_streak = False
            else:
                # Reverted or jumped — not a smooth continuation; restart from this frame.
                self._splice_reject_streak[device_address] = (new, now_ts, 1)
                new_streak = True
        else:
            # First heal-eligible reject for this device — start the streak.
            self._splice_reject_streak[device_address] = (new, now_ts, 1)
            new_streak = True

        self._bump(self.splice_held_count, device_address)
        if new_streak:
            # Log once per insistence run (not every poll), like the #286 scalar hold.
            _logger.info(
                "Battery bank for device 0x%02x: >=2-physics step is heal-eligible (voltage/capacity "
                "surge in range) — holding last-good, will adopt only after %.0f s of smooth in-range "
                "evolution. Trips: %s.",
                device_address,
                heal_seconds,
                self._format_splice_trips(phys),
            )
        return False

    @staticmethod
    def _fmt_scalar_sig(scalar_immut: list[tuple[int, str, int, int]]) -> str:
        """Render scalar-immutable trips compactly for the INFO logs, e.g. ``IR(98) 3005->3010``."""
        return ", ".join(f"IR({ir_no}) {old}->{new}" for ir_no, _name, old, new in scalar_immut)

    def _splice_burst_note_reject(
        self, device_address: int, trips: list[tuple[int, str, int, int]], now_ts: datetime
    ) -> tuple[bool, str]:
        """Track a hard reject in the per-device burst state; decide WARNING vs DEBUG (#355).

        Returns ``(warn_now, suffix)``: ``warn_now`` is True for a burst onset, a changed trip
        signature, or an over-interval escalation re-warn; the suffix carries the running tally on
        escalations. The signature is the SET of tripped (register, rule) pairs — value-agnostic,
        so jittering corrupt values don't defeat the coalescing.
        """
        sig = frozenset((ir, name) for ir, name, _old, _new in trips)
        burst = self._splice_reject_burst.get(device_address)
        if burst is None or burst[0] != sig:
            self._splice_reject_burst[device_address] = (sig, now_ts, 1, now_ts)
            return True, ""
        _, start, count, last_warn = burst
        count += 1
        if (now_ts - last_warn).total_seconds() >= SPLICE_REJECT_REWARN_SECONDS:
            self._splice_reject_burst[device_address] = (sig, start, count, now_ts)
            return True, f" (ongoing burst: {count} rejections over {(now_ts - start).total_seconds():.0f}s)"
        self._splice_reject_burst[device_address] = (sig, start, count, last_warn)
        return False, ""

    def _splice_burst_note_commit(self, device_address: int, now_ts: datetime) -> None:
        """Close out a reject burst on a successful commit; log the tally once (#355).

        Singleton rejections skip the clearing line — their onset WARNING already told the story.
        """
        burst = self._splice_reject_burst.pop(device_address, None)
        if burst is not None and burst[2] > 1:
            _logger.warning(
                "Battery bank for device 0x%02x: splice burst cleared after %d rejections over %.0fs "
                "— last-good was held throughout.",
                device_address,
                burst[2],
                (now_ts - burst[1]).total_seconds(),
            )

    @staticmethod
    def _format_splice_trips(trips: list[tuple[int, str, int, int]]) -> str:
        """Render splice-guard trips compactly for the WARNING log (raw register units)."""
        return ", ".join(f"IR({ir}) {name} {old}->{new}" for ir, name, old, new in trips)

    def _stamp_block(
        self,
        device_address: int,
        reg_type: str,
        base_register: int,
        register_count: int,
        received_at: datetime | None,
    ) -> None:
        """Record the ingestion time of a committed register block (#65).

        The key includes ``register_count`` so that a partial response (e.g. IR(0,1)) does
        not mark a full 60-register block as fresh — skip-if-fresh (#196) specifically checks
        for the IR(0,60) key (count=60). See Codex review on PR #208.
        """
        ts = received_at or datetime.now(UTC)
        if ts.tzinfo is None:
            ts = ts.replace(tzinfo=UTC)
        self.register_block_updated_at[(device_address, reg_type, base_register, register_count)] = ts

    @property
    def last_updated_at(self) -> datetime | None:
        """Newest register-block ingestion time across the whole plant, or None if never seen.

        The honest "is the cache being kept fresh" signal for a passive consumer (#65): it
        advances whenever ANY bank commits — solicited or fanned-out from a peer client —
        regardless of content, so a stale bus is visible as a signal that stops moving.
        Because it keys off commit rather than value, it is meaningful even on a Gateway
        (where the synthetic all-zeros inverter makes ``system_time`` spurious) and is
        topology-agnostic. ``None`` before the first commit (cold). Consumers derive age as
        ``now - last_updated_at``.
        """
        stamps = self.register_block_updated_at
        return max(stamps.values()) if stamps else None

    def block_age(
        self,
        device_address: int,
        reg_type: str,
        base_register: int,
        register_count: int,
        *,
        now: datetime | None = None,
    ) -> float | None:
        """Seconds since a register block was last committed, or None if never seen.

        ``reg_type`` is ``"HR"`` or ``"IR"``. ``register_count`` must match the count used
        when the block was stamped — typically 60 for standard GivEnergy IR/HR blocks. This
        prevents a partial response (e.g. IR(0,1)) from being mistaken for a full IR(0,60)
        block by the skip-if-fresh logic in refresh() (#196).

        Used to reason about freshness and staleness in the fan-out skip (#196) and Pattern B
        all-zero rejection (#206).
        """
        ts = self.register_block_updated_at.get((device_address, reg_type, base_register, register_count))
        if ts is None:
            return None
        effective_now = now or datetime.now(UTC)
        if effective_now.tzinfo is None:
            effective_now = effective_now.replace(tzinfo=UTC)
        return (effective_now - ts).total_seconds()

    def block_present(
        self,
        device_address: int,
        reg_type: str,
        base_register: int,
        register_count: int,
    ) -> bool | None:
        """Return presence status of a register block: True = present, False = absent, None = unknown.

        Parallel to ``block_age()``'s None-for-never semantics. Present means a bank was committed
        (``update()`` stamped it); absent means ``mark_absent()`` was called (detect found nothing
        usable); None means the block has never been attempted on this Plant instance.
        """
        return self.register_block_present.get((device_address, reg_type, base_register, register_count))

    def mark_absent(
        self,
        device_address: int,
        reg_type: str,
        base_register: int,
        register_count: int,
    ) -> None:
        """Record that a probe found this block absent (probe timeout, all-zero, or is_valid() False).

        Only called from detect-helper rejection sites in client.py, not from update() — a rejected
        bank during a live refresh is not "absent", just noisy. Consumers can read this to distinguish
        "device never responded" from "device unknown" without grepping logs.
        """
        self.register_block_present[(device_address, reg_type, base_register, register_count)] = False

    def invalidate_presence(
        self,
        device_address: int,
        reg_type: str,
        base_register: int,
        register_count: int,
    ) -> None:
        """Drop the presence marker, cached registers, and freshness stamps for a block → UNKNOWN.

        A stale "present" must not outlive a topology change: invalidating removes the marker so
        the next poll resolves absence/presence from scratch, clears the cached registers so the
        typed views don't serve stale data, and clears the freshness stamp so skip-if-fresh
        (``refresh(ir0_max_age=...)``) does not suppress the re-probe.
        """
        key = (device_address, reg_type, base_register, register_count)
        self.register_block_present.pop(key, None)
        self.register_block_updated_at.pop(key, None)
        self._block_unchanged_since.pop(key, None)
        cache = self.register_caches.get(device_address)
        if cache is not None:
            reg_cls = HR if reg_type == "HR" else IR
            for idx in range(base_register, base_register + register_count):
                cache.pop(reg_cls(idx), None)
        # Battery cold-start: a pending baseline for this device must not survive invalidation —
        # the next probe should start fresh, not corroborate against a pre-invalidation frame.
        if reg_type == "IR" and base_register == 60 and register_count == 60:
            self._splice_pending_baseline.pop(device_address, None)

    def register_age(
        self,
        device_address: int,
        register: Register,
        *,
        now: datetime | None = None,
    ) -> float | None:
        """Seconds since the freshest stamped block *containing* ``register`` was committed (#247).

        Unlike ``block_age()``, the caller doesn't need to know block boundaries or the
        stamped count — every stamped window for the device whose [base, base+count) span
        covers the register is considered, and the freshest wins. None if no stamped
        window covers it. Pair with ``RegisterGetter.registers_of()`` to reason about a
        model attribute's freshness.
        """
        effective_now = now or datetime.now(UTC)
        if effective_now.tzinfo is None:
            effective_now = effective_now.replace(tzinfo=UTC)
        best: datetime | None = None
        for (dev, reg_type, base, count), ts in self.register_block_updated_at.items():
            if dev == device_address and reg_type == register.reg_type and base <= register.index < base + count:
                if best is None or ts > best:
                    best = ts
        if best is None:
            return None
        return (effective_now - best).total_seconds()

    def _track_content_change(
        self,
        device_address: int,
        reg_type: str,
        base_register: int,
        register_count: int,
        committed: dict,
        received_at: datetime | None,
    ) -> None:
        """Maintain the (content_hash, unchanged_since) tracker for this block (#91).

        Called from update() whenever _commit_bank() returns True, mirroring _stamp_block().
        Hashes the full committed dict: if the hash matches the stored one the content is
        byte-identical to the previous commit and unchanged_since is left anchored at the first
        commit of the run; otherwise (changed content, or first sight) the hash and timestamp
        are reset. unchanged_since therefore marks when the current content first appeared, and
        content_unchanged_seconds() reads off how long it has held — the duration signal a freeze
        detector needs. O(1) per block; survives unchanged runs of any length.

        This is the staleness *primitive*, not a freeze verdict: replaying the real capture
        corpus showed healthy, actively-polled LV batteries hold byte-identical IR(60,60) content
        for streaks of 23-26 consecutive samples (dongle fan-out re-serves the same cached frame
        to every TCP client, and battery telemetry genuinely doesn't change every poll). A verdict
        needs a duration threshold validated against more than the single freeze capture we have.
        #57's bounds-discard calibration ("discarded bank matches last accepted vs genuinely new")
        reads the stored hash too. See #91.
        """
        ts = received_at or datetime.now(UTC)
        if ts.tzinfo is None:
            ts = ts.replace(tzinfo=UTC)
        key = (device_address, reg_type, base_register, register_count)
        content_hash = hash(frozenset(committed.items()))
        prev = self._block_unchanged_since.get(key)
        if prev is None or prev[0] != content_hash:
            self._block_unchanged_since[key] = (content_hash, ts)

    def content_unchanged_seconds(
        self,
        device_address: int,
        reg_type: str,
        base_register: int,
        register_count: int,
        *,
        now: datetime | None = None,
    ) -> float | None:
        """Seconds a register block's content has been byte-identical, or None if never seen (#91).

        Reports a raw duration, not a freeze verdict: a high value *may* indicate a frozen BMS
        cache (e.g. a battery whose BMS is in firmware-update bootloader mode), but on the real
        capture corpus healthy LV batteries also hold IR(60,60) content steady for long stretches
        (dongle fan-out + genuinely-static telemetry). Distinguishing a freeze from a live-but-
        static device needs a threshold validated against more freeze captures than currently
        exist, so this method deliberately makes no claim — callers compose it with their own
        policy. See #91.

        ``reg_type`` is ``"HR"`` or ``"IR"``; ``register_count`` must match the committed block.
        """
        entry = self._block_unchanged_since.get((device_address, reg_type, base_register, register_count))
        if entry is None:
            return None
        effective_now = now or datetime.now(UTC)
        if effective_now.tzinfo is None:
            effective_now = effective_now.replace(tzinfo=UTC)
        return (effective_now - entry[1]).total_seconds()

    @property
    def inverter(self) -> SinglePhaseInverter | ThreePhaseInverter:
        """Return the inverter model, dispatching on device type when capabilities are available.

        Tolerates the inverter-address cache not yet existing — a pre-#189 persisted
        capability may still point at 0x31, which detect() doesn't populate (it reads
        identity at 0x11), so this would otherwise KeyError between detect() and the
        first poll. Returns an empty-cache model in that window, matching the
        .ems / .gateway accessors. Without capabilities the inverter is read at its
        canonical address 0x11 (#352) — not 0x32, which is LV battery pack #1 — via
        ``.get`` since 0x11 is not pre-allocated on a bare Plant. (#119, #189)
        """
        if self.capabilities:
            cache = self.register_caches.get(self.capabilities.inverter_address, RegisterCache())
            return select_inverter(self.capabilities.device_type, cache)
        return SinglePhaseInverter.from_register_cache(self.register_caches.get(0x11, RegisterCache()))

    @property
    def inverter_serial(self) -> str:
        """Single authoritative inverter serial, robust across the whole plant lifecycle (#227).

        Resolves the earliest-available inverter identity by trying, in order:

        1. HR(13-17) in the capability-selected inverter cache (``inverter_address`` — 0x11
           since #189; 0x31 only via a pre-#189 persisted capability);
        2. HR(13-17) in the 0x11 cache — ``detect()``'s ``HR(0,60)`` identity read lands here for
           every model, so this covers the detect→first-refresh window when a stale capability
           still points at 0x31;
        3. the ``inverter_serial_number`` envelope field — populated at ``detect()`` and the only
           home on a persisted/bare plant carrying no register caches.

        A register block is only accepted if it decodes to a valid serial (``is_valid_serial`` —
        the same coherence gate ``_commit_bank`` ingestion uses), so a malformed/partial block in a
        restored or tampered cache falls through to the envelope rather than outranking it.

        Deliberately never reads the 0x32 battery cache, so a bare or pre-detect plant can't
        surface a battery pack's serial as the inverter's. Reads via ``.get()`` so it never
        mutates the (defaultdict) caches. Once consumers move to this accessor, the envelope
        field can be deprecated.
        """
        addresses = [0x11]
        # Prefer the capability-selected register home (0x11 since #189, or a persisted 0x31);
        # 0x32 is the battery pack and never a valid inverter address, so a stale pre-#119
        # 0x32 capability is ignored.
        if self.capabilities is not None and self.capabilities.inverter_address in (0x11, 0x31):
            addresses.insert(0, self.capabilities.inverter_address)
        for addr in dict.fromkeys(addresses):  # de-dup, preserve order
            cache = self.register_caches.get(addr)
            if cache is None:
                continue
            raw = [cache.get(HR(n)) for n in range(13, 18)]  # .get(): never mutate the defaultdict
            if any(v is None for v in raw):
                continue  # fail closed on a partially-present serial block
            serial = Converter.serial(*cast("list[int]", raw))
            # Coherence gate (same as _commit_bank ingestion): a complete block can still
            # decode to garbage — an interior zero register strips to a short string
            # (SA12\x00\x00G047 -> "SA12G047"), and a persisted/tampered cache can hold spaces
            # or other malformed data. is_valid_serial() requires a clean 10-char serial, so
            # such a block falls through to a known-good envelope serial rather than outranking it.
            if serial and is_valid_serial(serial):
                return serial
        return self.inverter_serial_number

    @property
    def number_batteries(self) -> int:
        """Determine the number of batteries connected to the system based on whether the register data is valid."""
        if self.capabilities:
            return len(self.capabilities.lv_battery_addresses)
        count = 0
        for i in range(6):
            try:
                battery = Battery.from_register_cache(self.register_caches[i + 0x32])
            except (KeyError, ValueError):
                # KeyError: no cache for that device yet. ValueError: an enum-typed
                # register held a value outside the known set. Either way, treat as
                # "not a battery" and stop probing rather than aborting the caller.
                break
            if not battery.is_valid():
                break
            count += 1
        return count

    @property
    def batteries(self) -> list[Battery]:
        """Return Battery models for the Plant.

        A capabilities-listed address with no register cache yields an all-None placeholder
        Battery at its index rather than being dropped (#213) — a transiently-absent pack
        (e.g. a BMS slow to answer after a restart) stays present-but-unavailable and index-
        aligned instead of shifting its siblings' entities. Subsequent refresh()es populate it
        if it responds.
        """
        if self.capabilities:
            return [
                Battery.from_register_cache(self.register_caches.get(addr, RegisterCache()))
                for addr in self.capabilities.lv_battery_addresses
            ]
        return [Battery.from_register_cache(self.register_caches[i + 0x32]) for i in range(self.number_batteries)]

    @property
    def remaining_battery_energy_wh(self) -> int | None:
        """Total remaining battery energy (Wh) summed over this Plant's LV packs (#374).

        Nominal-voltage basis (``Battery.remaining_energy_nominal_wh``). This sums every pack on
        *this* dongle, so on an inverter with a primary/secondary chain it includes the secondary
        pack the EMS controller's own figure (``Ems.remaining_battery_wh``, IR2091) drops.

        Returns ``None`` (not ``0``) when no pack decodes — e.g. an EMS-controller dongle has no
        LV battery sub-bus, so its Plant returns ``None`` rather than a misleading ``0 Wh``.

        Scope: one Plant is one dongle, and battery sub-bus addresses collide across managed
        inverters (each reuses 0x32-0x37), so this cannot span inverters. A consumer holding
        several inverter connections sums their per-Plant values for the site total.
        """
        present = [e for e in (b.remaining_energy_nominal_wh for b in self.batteries) if e is not None]
        return sum(present) if present else None

    @property
    def hv_stacks(self) -> list[HvStack]:
        """Return HV battery stacks (BCU + per-module BMUs) for HV systems; [] for LV systems.

        Each BMU is decoded from its **own** device-address cache (0x50 + running module index,
        contiguous across stacks), matching the separate-address AIO layout. Single-stack
        allocation is installer-confirmed; the multi-stack stride is not yet wire-confirmed
        (#265). Module caches that haven't been polled (or don't respond) decode to all-None and
        report ``is_valid() == False``.
        """
        if not self.capabilities or not self.capabilities.bcu_stacks:
            return []
        stacks = []
        next_bmu_addr = 0x50
        for offset, num_modules in self.capabilities.bcu_stacks:
            device_addr = 0x70 + offset
            cache = self.register_caches.get(device_addr, RegisterCache())
            bcu = Bcu.from_register_cache(cache)
            bmus = []
            for i in range(num_modules):
                bmu_cache = self.register_caches.get(next_bmu_addr + i, RegisterCache())
                bmus.append(Bmu.from_register_cache(bmu_cache, i))
            next_bmu_addr += num_modules
            stacks.append(HvStack(device_address=device_addr, bcu=bcu, bmus=bmus))
        return stacks

    @property
    def aio_battery_modules(self) -> list[AioBatteryModule]:
        """Return per-module AIO battery models (#192), one per separate-address module cache.

        All-in-One units expose each removable module at its own device address (0x50-0x53),
        each carrying 24 cell voltages, temperatures, and the module's own serial. Empty for
        non-AIO plants and until the module caches have been polled.
        """
        if not self.capabilities or not self.capabilities.aio_battery_module_addresses:
            return []
        modules = []
        for addr in self.capabilities.aio_battery_module_addresses:
            if addr in self.register_caches:
                try:
                    modules.append(AioBatteryModule.from_register_cache(self.register_caches[addr], addr))
                except Exception:
                    _logger.error("Failed to decode AIO battery module at 0x%02x", addr, exc_info=True)
        return modules

    @property
    def lv_bcu(self) -> LvBcu | None:
        """Return the LV BCU stack-level block, or None when absent.

        None when capabilities are unset, the block wasn't detected (firmware-gated —
        see model/lv_bcu.py), or its cache hasn't been populated yet.
        """
        if not self.capabilities or self.capabilities.lv_bcu_address is None:
            return None
        cache = self.register_caches.get(self.capabilities.lv_bcu_address)
        if not cache:
            return None
        try:
            return LvBcu.from_register_cache(cache)
        except Exception:
            _logger.error("Failed to decode LV BCU at 0x%02x", self.capabilities.lv_bcu_address, exc_info=True)
            return None

    @property
    def meters(self) -> dict[int, Meter]:
        """Return Meter models keyed by device address.

        A listed-but-uncached meter address yields an all-None placeholder Meter at its key
        rather than being omitted (#213), so a transiently-absent meter reads as present-but-
        unavailable and is repopulated on a later refresh() if it responds.
        """
        if not self.capabilities or not self.capabilities.meter_addresses:
            return {}
        return {
            addr: Meter.from_register_cache(self.register_caches.get(addr, RegisterCache()))
            for addr in self.capabilities.meter_addresses
        }

    @property
    def ems(self) -> Ems | None:
        """Return Ems model for EMS/EMS_COMMERCIAL device types; None otherwise."""
        if not self.capabilities or self.capabilities.device_type not in (Model.EMS, Model.EMS_COMMERCIAL):
            return None
        cache = self.register_caches.get(self.capabilities.inverter_address, RegisterCache())
        return Ems.from_register_cache(cache)

    def add_direct_source(self, caches: dict[int, RegisterCache]) -> None:
        """Store direct-inverter register caches for serial reconciliation (#106 Phase 3).

        Caches are stored separately from ``register_caches`` to avoid the Modbus
        address collision (both the EMS controller and a directly-connected inverter
        live at 0x11). Call this on an EMS plant after collecting data from a second
        Client pointing at one of the EMS-managed inverters; ``inverters`` and
        ``serial_index`` will then return merged views for matching serials.
        """
        self._direct_source_caches.extend(caches.values())

    @property
    def serial_index(self) -> dict[str, UnifiedInverter]:
        """Map each known inverter serial number to its :class:`Inverter` facade.

        Built from :attr:`inverters`, so the same reconciliation logic applies:
        merged entries (direct + EMS rollup) have ``data_source="merged"``,
        blinded EMS-only entries have ``data_source="ems_rollup"``, and direct-only
        entries have ``data_source="direct"``.
        """
        return {inv.serial_number: inv for inv in self.inverters if inv.serial_number}

    @property
    def inverters(self) -> list[UnifiedInverter]:
        """Return one :class:`Inverter` facade per inverter in this plant.

        For an EMS plant without direct sources: yields one :class:`Inverter` per
        non-empty managed-inverter slot in the EMS's IR(2040+) rollup
        (``data_source="ems_rollup"``).

        For an EMS plant with direct sources (injected via :meth:`add_direct_source`):
        reconciles EMS summaries with direct-inverter caches by serial number.
        Matching serials produce merged inverters (``data_source="merged"``); EMS
        slots without a matching direct source stay blinded; direct sources whose
        serial is not in the EMS rollup appear as orphan ``data_source="direct"``
        entries (#106 Phase 3).

        For a non-EMS plant: yields a single :class:`Inverter` wrapping the existing
        :attr:`inverter` (``data_source="direct"``). The legacy :attr:`inverter`
        (singular) accessor remains for back-compat.
        """
        if self.ems is not None:
            if not self._direct_source_caches:
                return [UnifiedInverter.from_summary(s) for s in self.ems.managed_inverters]

            # Decode each direct-source cache into a serial → concrete-inverter map.
            direct_by_serial: dict[str, Any] = {}
            for cache in self._direct_source_caches:
                raw_dtc = cache.get(HR(0))
                if raw_dtc is None:
                    continue
                arm_fw = cache.get(HR(21)) or 0
                model = resolve_model(raw_dtc, arm_fw)
                inv = select_inverter(model, cache)
                sn = getattr(inv, "serial_number", None)
                if sn:
                    direct_by_serial[sn] = inv

            result: list[UnifiedInverter] = []
            ems_serials: set[str] = set()
            for summary in self.ems.managed_inverters:
                sn = summary.serial_number
                ems_serials.add(sn)
                if sn in direct_by_serial:
                    result.append(UnifiedInverter.merge(direct_by_serial[sn], summary))
                else:
                    result.append(UnifiedInverter.from_summary(summary))

            # Orphan: direct-source inverters not present in EMS rollup
            for sn, direct_inv in direct_by_serial.items():
                if sn not in ems_serials:
                    result.append(UnifiedInverter.from_direct(direct_inv))

            return result

        # The single direct inverter owns every battery / HV stack / AIO module in
        # the plant cache (#106 Phase 2, #192). Inject the already-decoded sub-devices
        # so the facade can expose ownership without importing concrete Battery /
        # HvStack / AioBatteryModule types. Splitting across multiple direct inverters
        # is Phase 3.
        return [
            UnifiedInverter.from_direct(
                self.inverter,
                batteries=self.batteries,
                hv_stacks=self.hv_stacks,
                battery_modules=self.aio_battery_modules,
            )
        ]

    @property
    def gateway(self) -> GatewayV1 | GatewayV2 | None:
        """Return GatewayV1 or GatewayV2 model for GATEWAY device type; None otherwise."""
        if not self.capabilities or self.capabilities.device_type != Model.GATEWAY:
            return None
        cache = self.register_caches.get(self.capabilities.inverter_address, RegisterCache())
        return select_gateway(cache)

    @property
    def devices(self) -> list[PlantDevice]:
        """Canonical flat topology walk (#106): root first, children with parent refs.

        Returns exactly one root row (``parent=None``) — a GATEWAY, EMS, or
        INVERTER row depending on plant shape, in that priority order — and
        every other device as a flat child row parented to that root's
        ``identity``. Exactly one row is ``is_control_authority=True``: the
        root. Children are sorted by ``(device_type.value, identity)`` for a
        deterministic order.

        On an EMS plant, managed inverters are their own INVERTER child rows
        (identity ``{serial}_managed`` when blinded, else the inverter's own
        serial) rather than nested under the EMS row — a controller (EMS or
        gateway) never appears as an INVERTER row, and #106's flat contract
        means every device gets its own row with an explicit ``parent``.
        """
        caps = self.capabilities
        rows: list[PlantDevice] = []
        plant_serial = self.inverter_serial
        root_identity = plant_serial
        ems_root: Ems | None = None

        # --- root row: gateway > ems > inverter -------------------------------------
        if caps and caps.is_gateway and (gateway := self.gateway) is not None:
            rows.append(
                PlantDevice(
                    identity=root_identity,
                    device_type=DeviceType.GATEWAY,
                    parent=None,
                    device=gateway,
                    serial_number=plant_serial or None,
                    firmware_version=_walk_firmware(DeviceType.GATEWAY, gateway, self),
                    is_control_authority=True,
                )
            )
        elif (ems := self.ems) is not None:
            ems_root = ems
            rows.append(
                PlantDevice(
                    identity=root_identity,
                    device_type=DeviceType.EMS,
                    parent=None,
                    device=ems,
                    serial_number=plant_serial or None,
                    firmware_version=_walk_firmware(DeviceType.EMS, ems, self),
                    is_control_authority=True,
                )
            )
        else:
            uinvs = self.inverters
            root_uinv = uinvs[0] if uinvs else None
            rows.append(
                PlantDevice(
                    identity=root_identity,
                    device_type=DeviceType.INVERTER,
                    parent=None,
                    device=root_uinv,
                    serial_number=plant_serial or None,
                    firmware_version=_walk_firmware(DeviceType.INVERTER, root_uinv, self),
                    is_control_authority=True,
                    data_source=root_uinv.data_source if root_uinv is not None else None,
                )
            )

        child_rows: list[PlantDevice] = []

        # --- EMS-managed inverters: their own INVERTER rows, parented to the EMS root ---
        if ems_root is not None:
            for uinv in self.inverters:
                managed_serial = uinv.serial_number or ""
                if uinv.is_blinded:
                    identity = f"{managed_serial}_managed" if managed_serial else ""
                else:
                    identity = managed_serial
                child_rows.append(
                    PlantDevice(
                        identity=identity,
                        device_type=DeviceType.INVERTER,
                        parent=root_identity,
                        device=uinv,
                        serial_number=managed_serial or None,
                        firmware_version=None if uinv.is_blinded else _walk_firmware(DeviceType.INVERTER, uinv, self),
                        data_source=uinv.data_source,
                        is_valid=bool(managed_serial),
                    )
                )

        # --- LV batteries (incl. #213 placeholders) ----------------------------------
        for slot, battery in enumerate(self.batteries, start=1):
            batt_serial = _validated_serial(battery)
            child_rows.append(
                PlantDevice(
                    identity=batt_serial or f"{plant_serial}_battery_{slot}",
                    device_type=DeviceType.BATTERY,
                    parent=root_identity,
                    device=battery,
                    serial_number=batt_serial,
                    firmware_version=_walk_firmware(DeviceType.BATTERY, battery, self),
                    is_valid=battery.is_valid(),
                )
            )

        # --- AIO modules --------------------------------------------------------------
        # Keyed off each module's own module_address (set in
        # AioBatteryModule.from_register_cache) rather than zipped positionally against
        # capabilities.aio_battery_module_addresses -- aio_battery_modules() silently
        # skips any address absent from register_caches, so a missing earlier address
        # would otherwise shift every later module onto the wrong address (#106 I1).
        for module in self.aio_battery_modules:
            module_serial = _validated_serial(module)
            addr = getattr(module, "module_address", None)
            child_rows.append(
                PlantDevice(
                    identity=module_serial or f"{plant_serial}_module_{addr}",
                    device_type=DeviceType.BATTERY_MODULE,
                    parent=root_identity,
                    device=module,
                    serial_number=module_serial,
                    is_valid=module.is_valid(),
                )
            )

        # --- HV stacks + their BMUs ----------------------------------------------------
        # AIO systems answer the same 0x50-band addresses as both Bmu (via bcu_stacks)
        # and AioBatteryModule (via aio_battery_module_addresses) — when the latter is
        # configured, the per-module rows above already cover these devices, so skip the
        # BMU rows here to avoid emitting the same physical module twice.
        aio_covers_bmus = bool(caps and caps.aio_battery_module_addresses)
        for stack in self.hv_stacks:
            stack_identity = f"{plant_serial}_hvstack_{stack.device_address:#04x}"
            child_rows.append(
                PlantDevice(
                    identity=stack_identity,
                    device_type=DeviceType.HV_STACK,
                    parent=root_identity,
                    device=stack,
                    serial_number=_validated_serial(stack.bcu),
                    firmware_version=_walk_firmware(DeviceType.HV_STACK, stack, self),
                    is_valid=stack.bcu.is_valid(),
                )
            )
            if aio_covers_bmus:
                continue
            for n, bmu in enumerate(stack.bmus, start=1):
                bmu_serial = _validated_serial(bmu)
                child_rows.append(
                    PlantDevice(
                        identity=bmu_serial or f"{stack_identity}_bmu_{n}",
                        device_type=DeviceType.BATTERY_MODULE,
                        parent=stack_identity,
                        device=bmu,
                        serial_number=bmu_serial,
                        is_valid=bool(bmu_serial),
                    )
                )

        # --- meters (serial-first identity; #213 placeholders included) ----------------
        for addr, meter in self.meters.items():
            meter_serial = _validated_serial(meter)
            child_rows.append(
                PlantDevice(
                    identity=meter_serial or f"{plant_serial}_meter_{addr}",
                    device_type=DeviceType.METER,
                    parent=root_identity,
                    device=meter,
                    serial_number=meter_serial,
                    is_valid=meter.is_valid(),
                )
            )

        child_rows.sort(key=lambda r: (r.device_type.value, r.identity))
        return rows + child_rows

aio_battery_modules property

Return per-module AIO battery models (#192), one per separate-address module cache.

All-in-One units expose each removable module at its own device address (0x50-0x53), each carrying 24 cell voltages, temperatures, and the module's own serial. Empty for non-AIO plants and until the module caches have been polled.

batteries property

Return Battery models for the Plant.

A capabilities-listed address with no register cache yields an all-None placeholder Battery at its index rather than being dropped (#213) — a transiently-absent pack (e.g. a BMS slow to answer after a restart) stays present-but-unavailable and index- aligned instead of shifting its siblings' entities. Subsequent refresh()es populate it if it responds.

devices property

Canonical flat topology walk (#106): root first, children with parent refs.

Returns exactly one root row (parent=None) — a GATEWAY, EMS, or INVERTER row depending on plant shape, in that priority order — and every other device as a flat child row parented to that root's identity. Exactly one row is is_control_authority=True: the root. Children are sorted by (device_type.value, identity) for a deterministic order.

On an EMS plant, managed inverters are their own INVERTER child rows (identity {serial}_managed when blinded, else the inverter's own serial) rather than nested under the EMS row — a controller (EMS or gateway) never appears as an INVERTER row, and #106's flat contract means every device gets its own row with an explicit parent.

ems property

Return Ems model for EMS/EMS_COMMERCIAL device types; None otherwise.

gateway property

Return GatewayV1 or GatewayV2 model for GATEWAY device type; None otherwise.

hv_stacks property

Return HV battery stacks (BCU + per-module BMUs) for HV systems; [] for LV systems.

Each BMU is decoded from its own device-address cache (0x50 + running module index, contiguous across stacks), matching the separate-address AIO layout. Single-stack allocation is installer-confirmed; the multi-stack stride is not yet wire-confirmed (#265). Module caches that haven't been polled (or don't respond) decode to all-None and report is_valid() == False.

inverter property

Return the inverter model, dispatching on device type when capabilities are available.

Tolerates the inverter-address cache not yet existing — a pre-#189 persisted capability may still point at 0x31, which detect() doesn't populate (it reads identity at 0x11), so this would otherwise KeyError between detect() and the first poll. Returns an empty-cache model in that window, matching the .ems / .gateway accessors. Without capabilities the inverter is read at its canonical address 0x11 (#352) — not 0x32, which is LV battery pack #1 — via .get since 0x11 is not pre-allocated on a bare Plant. (#119, #189)

inverter_serial property

Single authoritative inverter serial, robust across the whole plant lifecycle (#227).

Resolves the earliest-available inverter identity by trying, in order:

  1. HR(13-17) in the capability-selected inverter cache (inverter_address — 0x11 since #189; 0x31 only via a pre-#189 persisted capability);
  2. HR(13-17) in the 0x11 cache — detect()'s HR(0,60) identity read lands here for every model, so this covers the detect→first-refresh window when a stale capability still points at 0x31;
  3. the inverter_serial_number envelope field — populated at detect() and the only home on a persisted/bare plant carrying no register caches.

A register block is only accepted if it decodes to a valid serial (is_valid_serial — the same coherence gate _commit_bank ingestion uses), so a malformed/partial block in a restored or tampered cache falls through to the envelope rather than outranking it.

Deliberately never reads the 0x32 battery cache, so a bare or pre-detect plant can't surface a battery pack's serial as the inverter's. Reads via .get() so it never mutates the (defaultdict) caches. Once consumers move to this accessor, the envelope field can be deprecated.

inverters property

Return one :class:Inverter facade per inverter in this plant.

For an EMS plant without direct sources: yields one :class:Inverter per non-empty managed-inverter slot in the EMS's IR(2040+) rollup (data_source="ems_rollup").

For an EMS plant with direct sources (injected via :meth:add_direct_source): reconciles EMS summaries with direct-inverter caches by serial number. Matching serials produce merged inverters (data_source="merged"); EMS slots without a matching direct source stay blinded; direct sources whose serial is not in the EMS rollup appear as orphan data_source="direct" entries (#106 Phase 3).

For a non-EMS plant: yields a single :class:Inverter wrapping the existing :attr:inverter (data_source="direct"). The legacy :attr:inverter (singular) accessor remains for back-compat.

last_updated_at property

Newest register-block ingestion time across the whole plant, or None if never seen.

The honest "is the cache being kept fresh" signal for a passive consumer (#65): it advances whenever ANY bank commits — solicited or fanned-out from a peer client — regardless of content, so a stale bus is visible as a signal that stops moving. Because it keys off commit rather than value, it is meaningful even on a Gateway (where the synthetic all-zeros inverter makes system_time spurious) and is topology-agnostic. None before the first commit (cold). Consumers derive age as now - last_updated_at.

lv_bcu property

Return the LV BCU stack-level block, or None when absent.

None when capabilities are unset, the block wasn't detected (firmware-gated — see model/lv_bcu.py), or its cache hasn't been populated yet.

meters property

Return Meter models keyed by device address.

A listed-but-uncached meter address yields an all-None placeholder Meter at its key rather than being omitted (#213), so a transiently-absent meter reads as present-but- unavailable and is repopulated on a later refresh() if it responds.

number_batteries property

Determine the number of batteries connected to the system based on whether the register data is valid.

remaining_battery_energy_wh property

Total remaining battery energy (Wh) summed over this Plant's LV packs (#374).

Nominal-voltage basis (Battery.remaining_energy_nominal_wh). This sums every pack on this dongle, so on an inverter with a primary/secondary chain it includes the secondary pack the EMS controller's own figure (Ems.remaining_battery_wh, IR2091) drops.

Returns None (not 0) when no pack decodes — e.g. an EMS-controller dongle has no LV battery sub-bus, so its Plant returns None rather than a misleading 0 Wh.

Scope: one Plant is one dongle, and battery sub-bus addresses collide across managed inverters (each reuses 0x32-0x37), so this cannot span inverters. A consumer holding several inverter connections sums their per-Plant values for the site total.

serial_index property

Map each known inverter serial number to its :class:Inverter facade.

Built from :attr:inverters, so the same reconciliation logic applies: merged entries (direct + EMS rollup) have data_source="merged", blinded EMS-only entries have data_source="ems_rollup", and direct-only entries have data_source="direct".

add_direct_source(caches)

Store direct-inverter register caches for serial reconciliation (#106 Phase 3).

Caches are stored separately from register_caches to avoid the Modbus address collision (both the EMS controller and a directly-connected inverter live at 0x11). Call this on an EMS plant after collecting data from a second Client pointing at one of the EMS-managed inverters; inverters and serial_index will then return merged views for matching serials.

Source code in givenergy_modbus/model/plant.py
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
def add_direct_source(self, caches: dict[int, RegisterCache]) -> None:
    """Store direct-inverter register caches for serial reconciliation (#106 Phase 3).

    Caches are stored separately from ``register_caches`` to avoid the Modbus
    address collision (both the EMS controller and a directly-connected inverter
    live at 0x11). Call this on an EMS plant after collecting data from a second
    Client pointing at one of the EMS-managed inverters; ``inverters`` and
    ``serial_index`` will then return merged views for matching serials.
    """
    self._direct_source_caches.extend(caches.values())

block_age(device_address, reg_type, base_register, register_count, *, now=None)

Seconds since a register block was last committed, or None if never seen.

reg_type is "HR" or "IR". register_count must match the count used when the block was stamped — typically 60 for standard GivEnergy IR/HR blocks. This prevents a partial response (e.g. IR(0,1)) from being mistaken for a full IR(0,60) block by the skip-if-fresh logic in refresh() (#196).

Used to reason about freshness and staleness in the fan-out skip (#196) and Pattern B all-zero rejection (#206).

Source code in givenergy_modbus/model/plant.py
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
def block_age(
    self,
    device_address: int,
    reg_type: str,
    base_register: int,
    register_count: int,
    *,
    now: datetime | None = None,
) -> float | None:
    """Seconds since a register block was last committed, or None if never seen.

    ``reg_type`` is ``"HR"`` or ``"IR"``. ``register_count`` must match the count used
    when the block was stamped — typically 60 for standard GivEnergy IR/HR blocks. This
    prevents a partial response (e.g. IR(0,1)) from being mistaken for a full IR(0,60)
    block by the skip-if-fresh logic in refresh() (#196).

    Used to reason about freshness and staleness in the fan-out skip (#196) and Pattern B
    all-zero rejection (#206).
    """
    ts = self.register_block_updated_at.get((device_address, reg_type, base_register, register_count))
    if ts is None:
        return None
    effective_now = now or datetime.now(UTC)
    if effective_now.tzinfo is None:
        effective_now = effective_now.replace(tzinfo=UTC)
    return (effective_now - ts).total_seconds()

block_present(device_address, reg_type, base_register, register_count)

Return presence status of a register block: True = present, False = absent, None = unknown.

Parallel to block_age()'s None-for-never semantics. Present means a bank was committed (update() stamped it); absent means mark_absent() was called (detect found nothing usable); None means the block has never been attempted on this Plant instance.

Source code in givenergy_modbus/model/plant.py
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
def block_present(
    self,
    device_address: int,
    reg_type: str,
    base_register: int,
    register_count: int,
) -> bool | None:
    """Return presence status of a register block: True = present, False = absent, None = unknown.

    Parallel to ``block_age()``'s None-for-never semantics. Present means a bank was committed
    (``update()`` stamped it); absent means ``mark_absent()`` was called (detect found nothing
    usable); None means the block has never been attempted on this Plant instance.
    """
    return self.register_block_present.get((device_address, reg_type, base_register, register_count))

content_unchanged_seconds(device_address, reg_type, base_register, register_count, *, now=None)

Seconds a register block's content has been byte-identical, or None if never seen (#91).

Reports a raw duration, not a freeze verdict: a high value may indicate a frozen BMS cache (e.g. a battery whose BMS is in firmware-update bootloader mode), but on the real capture corpus healthy LV batteries also hold IR(60,60) content steady for long stretches (dongle fan-out + genuinely-static telemetry). Distinguishing a freeze from a live-but- static device needs a threshold validated against more freeze captures than currently exist, so this method deliberately makes no claim — callers compose it with their own policy. See #91.

reg_type is "HR" or "IR"; register_count must match the committed block.

Source code in givenergy_modbus/model/plant.py
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
def content_unchanged_seconds(
    self,
    device_address: int,
    reg_type: str,
    base_register: int,
    register_count: int,
    *,
    now: datetime | None = None,
) -> float | None:
    """Seconds a register block's content has been byte-identical, or None if never seen (#91).

    Reports a raw duration, not a freeze verdict: a high value *may* indicate a frozen BMS
    cache (e.g. a battery whose BMS is in firmware-update bootloader mode), but on the real
    capture corpus healthy LV batteries also hold IR(60,60) content steady for long stretches
    (dongle fan-out + genuinely-static telemetry). Distinguishing a freeze from a live-but-
    static device needs a threshold validated against more freeze captures than currently
    exist, so this method deliberately makes no claim — callers compose it with their own
    policy. See #91.

    ``reg_type`` is ``"HR"`` or ``"IR"``; ``register_count`` must match the committed block.
    """
    entry = self._block_unchanged_since.get((device_address, reg_type, base_register, register_count))
    if entry is None:
        return None
    effective_now = now or datetime.now(UTC)
    if effective_now.tzinfo is None:
        effective_now = effective_now.replace(tzinfo=UTC)
    return (effective_now - entry[1]).total_seconds()

from_caches(register_caches, prior=None, *, inverter_serial_number='', data_adapter_serial_number='') classmethod

Build a fully-typed Plant from an injected register-cache set, with no live client (#268).

Capabilities are derived from the caches (no wire I/O) via :func:_derive_capabilities, so every typed view (.inverter/.batteries/.meters/.hv_stacks/.ems/ .inverters) works exactly as on a live-detected plant. The recipe for a capture dump is Plant.from_caches(plant_from_capture(path).register_caches).

Raises :class:CommunicationError if HR(0) is absent at device 0x11.

Source code in givenergy_modbus/model/plant.py
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
@classmethod
def from_caches(
    cls,
    register_caches: dict[int, RegisterCache],
    prior: PlantCapabilities | None = None,
    *,
    inverter_serial_number: str = "",
    data_adapter_serial_number: str = "",
) -> "Plant":
    """Build a fully-typed Plant from an injected register-cache set, with no live client (#268).

    Capabilities are derived from the caches (no wire I/O) via :func:`_derive_capabilities`, so
    every typed view (``.inverter``/``.batteries``/``.meters``/``.hv_stacks``/``.ems``/
    ``.inverters``) works exactly as on a live-detected plant. The recipe for a capture dump is
    ``Plant.from_caches(plant_from_capture(path).register_caches)``.

    Raises :class:`CommunicationError` if HR(0) is absent at device 0x11.
    """
    plant = cls(
        register_caches=register_caches,
        inverter_serial_number=inverter_serial_number,
        data_adapter_serial_number=data_adapter_serial_number,
    )
    plant.capabilities = _derive_capabilities(register_caches, prior)
    return plant

invalidate_presence(device_address, reg_type, base_register, register_count)

Drop the presence marker, cached registers, and freshness stamps for a block → UNKNOWN.

A stale "present" must not outlive a topology change: invalidating removes the marker so the next poll resolves absence/presence from scratch, clears the cached registers so the typed views don't serve stale data, and clears the freshness stamp so skip-if-fresh (refresh(ir0_max_age=...)) does not suppress the re-probe.

Source code in givenergy_modbus/model/plant.py
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
def invalidate_presence(
    self,
    device_address: int,
    reg_type: str,
    base_register: int,
    register_count: int,
) -> None:
    """Drop the presence marker, cached registers, and freshness stamps for a block → UNKNOWN.

    A stale "present" must not outlive a topology change: invalidating removes the marker so
    the next poll resolves absence/presence from scratch, clears the cached registers so the
    typed views don't serve stale data, and clears the freshness stamp so skip-if-fresh
    (``refresh(ir0_max_age=...)``) does not suppress the re-probe.
    """
    key = (device_address, reg_type, base_register, register_count)
    self.register_block_present.pop(key, None)
    self.register_block_updated_at.pop(key, None)
    self._block_unchanged_since.pop(key, None)
    cache = self.register_caches.get(device_address)
    if cache is not None:
        reg_cls = HR if reg_type == "HR" else IR
        for idx in range(base_register, base_register + register_count):
            cache.pop(reg_cls(idx), None)
    # Battery cold-start: a pending baseline for this device must not survive invalidation —
    # the next probe should start fresh, not corroborate against a pre-invalidation frame.
    if reg_type == "IR" and base_register == 60 and register_count == 60:
        self._splice_pending_baseline.pop(device_address, None)

mark_absent(device_address, reg_type, base_register, register_count)

Record that a probe found this block absent (probe timeout, all-zero, or is_valid() False).

Only called from detect-helper rejection sites in client.py, not from update() — a rejected bank during a live refresh is not "absent", just noisy. Consumers can read this to distinguish "device never responded" from "device unknown" without grepping logs.

Source code in givenergy_modbus/model/plant.py
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
def mark_absent(
    self,
    device_address: int,
    reg_type: str,
    base_register: int,
    register_count: int,
) -> None:
    """Record that a probe found this block absent (probe timeout, all-zero, or is_valid() False).

    Only called from detect-helper rejection sites in client.py, not from update() — a rejected
    bank during a live refresh is not "absent", just noisy. Consumers can read this to distinguish
    "device never responded" from "device unknown" without grepping logs.
    """
    self.register_block_present[(device_address, reg_type, base_register, register_count)] = False

model_post_init(__context)

Ensure a default register cache is always present.

Source code in givenergy_modbus/model/plant.py
840
841
842
843
def model_post_init(self, __context: Any) -> None:
    """Ensure a default register cache is always present."""
    if not self.register_caches:
        self.register_caches = {0x32: RegisterCache()}

record_retry(device_address)

Record a consumed read retry for a device (#284); called by the Client per retry.

Source code in givenergy_modbus/model/plant.py
947
948
949
def record_retry(self, device_address: int) -> None:
    """Record a consumed read retry for a device (#284); called by the Client per retry."""
    self._bump(self.retry_count, device_address)

redact()

Return a share-safe copy: every register cache redacted and the header serials cleared.

redact_serials() only covers the register caches; inverter_serial_number and data_adapter_serial_number live on the Plant itself (populated from the PDU envelope), so a dumped Plant still leaks both unless they're redacted here too. The original is left untouched (#212/#214 share-safe-export guarantee).

Source code in givenergy_modbus/model/plant.py
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
def redact(self) -> "Plant":
    """Return a share-safe copy: every register cache redacted and the header serials cleared.

    ``redact_serials()`` only covers the register caches; ``inverter_serial_number`` and
    ``data_adapter_serial_number`` live on the Plant itself (populated from the PDU envelope),
    so a dumped Plant still leaks both unless they're redacted here too. The original is left
    untouched (#212/#214 share-safe-export guarantee).
    """
    from givenergy_modbus.model.register import Converter

    # Fail closed: redact_serial_strict blanks any unrecognised identifier rather than leaking
    # it verbatim (redact_serial is fail-open). register_block_updated_at is copied so the
    # redacted snapshot stays independent of later updates to the original.
    return self.model_copy(
        update={
            "register_caches": {addr: cache.redact_serials() for addr, cache in self.register_caches.items()},
            "inverter_serial_number": Converter.redact_serial_strict(self.inverter_serial_number),
            "data_adapter_serial_number": Converter.redact_serial_strict(self.data_adapter_serial_number),
            "register_block_updated_at": dict(self.register_block_updated_at),
            "register_block_present": dict(self.register_block_present),
        }
    )

register_age(device_address, register, *, now=None)

Seconds since the freshest stamped block containing register was committed (#247).

Unlike block_age(), the caller doesn't need to know block boundaries or the stamped count — every stamped window for the device whose [base, base+count) span covers the register is considered, and the freshest wins. None if no stamped window covers it. Pair with RegisterGetter.registers_of() to reason about a model attribute's freshness.

Source code in givenergy_modbus/model/plant.py
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
def register_age(
    self,
    device_address: int,
    register: Register,
    *,
    now: datetime | None = None,
) -> float | None:
    """Seconds since the freshest stamped block *containing* ``register`` was committed (#247).

    Unlike ``block_age()``, the caller doesn't need to know block boundaries or the
    stamped count — every stamped window for the device whose [base, base+count) span
    covers the register is considered, and the freshest wins. None if no stamped
    window covers it. Pair with ``RegisterGetter.registers_of()`` to reason about a
    model attribute's freshness.
    """
    effective_now = now or datetime.now(UTC)
    if effective_now.tzinfo is None:
        effective_now = effective_now.replace(tzinfo=UTC)
    best: datetime | None = None
    for (dev, reg_type, base, count), ts in self.register_block_updated_at.items():
        if dev == device_address and reg_type == register.reg_type and base <= register.index < base + count:
            if best is None or ts > best:
                best = ts
    if best is None:
        return None
    return (effective_now - best).total_seconds()

update(pdu, *, received_at=None)

Update the Plant state from a PDU message.

received_at overrides the ingestion timestamp recorded for a committed register block (see register_block_updated_at / #65); it defaults to the current UTC time and is provided mainly for deterministic testing and replay.

Source code in givenergy_modbus/model/plant.py
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
def update(self, pdu: ClientIncomingMessage, *, received_at: datetime | None = None):
    """Update the Plant state from a PDU message.

    ``received_at`` overrides the ingestion timestamp recorded for a committed
    register block (see ``register_block_updated_at`` / #65); it defaults to the
    current UTC time and is provided mainly for deterministic testing and replay.
    """
    if not isinstance(pdu, TransparentResponse):
        _logger.debug(f"Ignoring non-Transparent response {pdu}")
        return
    if isinstance(pdu, NullResponse):
        _logger.debug(f"Ignoring Null response {pdu}")
        return
    if pdu.error:
        _logger.debug(f"Ignoring error response {pdu}")
        return
    _logger.debug(f"Handling {pdu}")

    # Reject CRC-failed frames before ANY Plant state is mutated. The CRC spans the device
    # address and serial fields in the envelope, so those are untrusted on exactly the frames
    # that fail here — a corrupt 0x11 response must not clobber the stable inverter identity.
    if getattr(pdu, "crc_failed", False) and not getattr(pdu, "lenient_crc_commit", False):
        self._bump(self.crc_failure_count, pdu.device_address)
        _logger.warning(
            "Skipping CRC-failed response from 0x%02x (base=%d) — no Plant state updated",
            pdu.device_address,
            getattr(pdu, "base_register", 0),
        )
        return

    # Store responses under their true wire device address. The old 0x11/0x00 → 0x32
    # fold was a courtesy to GivEnergy's cloud, not an inverter requirement: querying
    # 0x11 was relayed upstream by the dongle, and sub-5-minute polling there disturbed
    # their 5-minute dashboards — 0x32 was the side-door that left the cloud product
    # alone (0x00 was app traffic folded in on an assumption). Both rationales are now
    # moot (cloud is premium-only). The fold masked that 0x11 is the inverter's
    # canonical address and 0x32 is LV battery pack #1 (issue #119); detect() now
    # resolves the inverter address per model and reads/caches consistently at it.
    device_address = pdu.device_address

    if device_address not in self.register_caches:
        _logger.debug(f"First time encountering device address 0x{device_address:02x}")
        self.register_caches[device_address] = RegisterCache()

    # The TCP dongle's serial is identical on every response regardless of the addressed
    # downstream device (verified across the AIO capture: meters, inverter, modules, BCU and
    # BMS all carry the same data_adapter serial), so adopt it from any accepted PDU.
    self.data_adapter_serial_number = pdu.data_adapter_serial_number

    # inverter_serial_number, by contrast, is the *addressed device's* serial in the envelope:
    # battery (0x32-0x37), BCU/BMS (0x70+/0xA0) and AIO battery-module (0x50-0x53) responses
    # carry their own, so adopting it from every PDU let whichever device was polled last
    # clobber the real inverter serial — merging the AIO inverter HA device into a battery
    # module downstream (givenergy-hass#95). The inverter is canonically addressed at 0x11
    # (#189), with 0x31 a hardware facade on AC/HYBRID_GEN1 that other bus consumers may
    # still poll, so gate on that pair — it excludes peripherals and the legacy 0x32
    # (battery pack #1) a pre-#119 persisted capability may still carry until detect()
    # self-heals.
    if device_address in (0x11, 0x31):
        self.inverter_serial_number = pdu.inverter_serial_number

    if isinstance(pdu, ReadHoldingRegistersResponse):
        incoming = {HR(k): v for k, v in pdu.to_dict().items()}
        if self._commit_bank(device_address, incoming, pdu.register_count, received_at=received_at):
            self._stamp_block(device_address, "HR", pdu.base_register, pdu.register_count, received_at)
            self.register_block_present[(device_address, "HR", pdu.base_register, pdu.register_count)] = True
            self._track_content_change(
                device_address, "HR", pdu.base_register, pdu.register_count, incoming, received_at
            )
    elif isinstance(pdu, ReadInputRegistersResponse):
        if pdu.is_suspicious():
            # Pattern A dongle-side substitution from #78 — known fingerprint of 16 fixed
            # constants. is_suspicious() logs at debug when it fires.
            return
        incoming = {IR(k): v for k, v in pdu.to_dict().items()}  # type: ignore[misc]
        if self._commit_bank(device_address, incoming, pdu.register_count, received_at=received_at):
            self._stamp_block(device_address, "IR", pdu.base_register, pdu.register_count, received_at)
            self.register_block_present[(device_address, "IR", pdu.base_register, pdu.register_count)] = True
            self._track_content_change(
                device_address, "IR", pdu.base_register, pdu.register_count, incoming, received_at
            )
    elif isinstance(pdu, WriteHoldingRegisterResponse):
        if pdu.register == 0:
            _logger.warning(f"Ignoring, likely corrupt: {pdu}")
        else:
            # Writes target the inverter and the echo comes back on the write address
            # (0x11), and the model reads caps.inverter_address. Since #189 unified
            # addressing on 0x11 the two normally coincide, but a pre-#189 persisted
            # capability may still say 0x31 (the AC/HYBRID_GEN1 facade), so keep routing
            # the echo to where reads land — otherwise plant.inverter won't reflect the
            # write until the next load_config(), and refresh() (IR-only) never will. The
            # cache may not exist yet if the write precedes the first read there.
            target = self.capabilities.inverter_address if self.capabilities is not None else device_address
            self.register_caches.setdefault(target, RegisterCache()).update({HR(pdu.register): pdu.value})

PlantCapabilities

Bases: BaseModel

Describes the hardware topology discovered by Client.detect().

Returned by Client.detect(); callers assign it to plant.capabilities or persist it for faster restarts (see fork-merge-plan deferred items).

Legacy *_slave(s) keyword aliases are mapped to the canonical names in __init__ (for PlantCapabilities(...) callers) and again in the _accept_legacy_aliases model_validator (for model_validate({...}) callers). Both paths emit a DeprecationWarning.

Source code in givenergy_modbus/model/plant.py
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
class PlantCapabilities(BaseModel):
    """Describes the hardware topology discovered by Client.detect().

    Returned by Client.detect(); callers assign it to plant.capabilities or
    persist it for faster restarts (see fork-merge-plan deferred items).

    Legacy ``*_slave(s)`` keyword aliases are mapped to the canonical names
    in ``__init__`` (for ``PlantCapabilities(...)`` callers) and again in the
    ``_accept_legacy_aliases`` model_validator (for ``model_validate({...})``
    callers). Both paths emit a DeprecationWarning.
    """

    # `extra="forbid"` preserves the historic contract that unknown kwargs
    # raise TypeError. The pre-Pydantic __init__ enforced this manually.
    model_config = ConfigDict(extra="forbid")

    device_type: Model
    inverter_address: int = 0x11  # 0x11 since #189; _derive_inverter_address overwrites per model (#352)
    meter_addresses: list[int] = Field(default_factory=list)
    lv_battery_addresses: list[int] = Field(default_factory=list)
    # Each entry is (bcu_offset, num_modules) where the BCU device address is 0x70 + bcu_offset.
    bcu_stacks: list[tuple[int, int]] = Field(default_factory=list)
    # AIO (All-in-One) per-module battery device addresses (0x50-0x53). Separate-address
    # layout, distinct from the bcu_stacks stride model — see model/aio_battery.py (#192).
    aio_battery_module_addresses: list[int] = Field(default_factory=list)
    # HV BMU per-module device addresses (0x50+), populated by detect for non-AIO HV stacks.
    # Each module answers at its own IR(60-119) cache (same separate-address layout as AIO);
    # installer-confirmed, not yet wire-confirmed — see model/hv_bcu.py (#265).
    hv_bmu_addresses: list[int] = Field(default_factory=list)
    # LV BCU page address (observed only at 0x31 — see model/lv_bcu.py). None when the
    # block read all-zero at detect time (firmware-gated, #241).
    lv_bcu_address: int | None = None
    # ARM firmware version, read from HR(21) at detect() time. None if not yet known
    # (e.g. a pre-Slice-B persisted payload). Enables firmware-gated capability facts
    # such as has_extended_slots (#293 Slice B).
    arm_firmware_version: int | None = None

    def __init__(
        self,
        device_type: Model | None = None,
        inverter_address: int | None = None,
        meter_addresses: list[int] | None = None,
        lv_battery_addresses: list[int] | None = None,
        bcu_stacks: list[tuple[int, int]] | None = None,
        aio_battery_module_addresses: list[int] | None = None,
        lv_bcu_address: int | None = None,
        hv_bmu_addresses: list[int] | None = None,
        arm_firmware_version: int | None = None,
        **kwargs: Any,
    ) -> None:
        # Custom __init__ for two reasons:
        # 1. Preserves the historic positional-argument shape that the
        #    @dataclass form supported (PlantCapabilities(Model.HYBRID, 0x32, ...)).
        # 2. Lets us emit the legacy-alias DeprecationWarning at stacklevel=2
        #    pointing at the user's call site — a `model_validator(mode='before')`
        #    sits behind Pydantic internals and can't reach the caller cleanly.
        # Only pass through positional-derived values that were actually supplied
        # so we don't override kwargs the caller may have provided as keywords.
        if device_type is not None:
            kwargs["device_type"] = device_type
        if inverter_address is not None:
            kwargs["inverter_address"] = inverter_address
        if meter_addresses is not None:
            kwargs["meter_addresses"] = meter_addresses
        if lv_battery_addresses is not None:
            kwargs["lv_battery_addresses"] = lv_battery_addresses
        if bcu_stacks is not None:
            kwargs["bcu_stacks"] = bcu_stacks
        if aio_battery_module_addresses is not None:
            kwargs["aio_battery_module_addresses"] = aio_battery_module_addresses
        if lv_bcu_address is not None:
            kwargs["lv_bcu_address"] = lv_bcu_address
        if hv_bmu_addresses is not None:
            kwargs["hv_bmu_addresses"] = hv_bmu_addresses
        if arm_firmware_version is not None:
            kwargs["arm_firmware_version"] = arm_firmware_version
        _map_legacy_aliases(kwargs, stacklevel=3)
        super().__init__(**kwargs)

    @model_validator(mode="before")
    @classmethod
    def _accept_legacy_aliases(cls, data: Any) -> Any:
        """Mirror the __init__ alias handling for the ``model_validate`` path.

        ``PlantCapabilities.model_validate({'inverter_slave': 0x33})`` bypasses
        ``__init__``, so we need a validator to catch legacy keys arriving via
        that route. Stacklevel from inside a validator can't reliably reach the
        user (Pydantic internals sit between), but the warning still fires under
        ``pytest.warns`` and ``warnings.catch_warnings`` filtering by category.
        """
        if not isinstance(data, dict):
            return data
        normalised = dict(data)
        _map_legacy_aliases(normalised, stacklevel=2)
        _derive_inverter_address(normalised)
        return normalised

    @property
    def inverter_slave(self) -> int:
        """Deprecated alias for `inverter_address`."""
        warnings.warn(
            "PlantCapabilities.inverter_slave is deprecated; use inverter_address",
            DeprecationWarning,
            stacklevel=2,
        )
        return self.inverter_address

    @inverter_slave.setter
    def inverter_slave(self, value: int) -> None:
        warnings.warn(
            "PlantCapabilities.inverter_slave is deprecated; use inverter_address",
            DeprecationWarning,
            stacklevel=2,
        )
        self.inverter_address = value

    @property
    def meter_slaves(self) -> list[int]:
        """Deprecated alias for `meter_addresses`."""
        warnings.warn(
            "PlantCapabilities.meter_slaves is deprecated; use meter_addresses",
            DeprecationWarning,
            stacklevel=2,
        )
        return self.meter_addresses

    @meter_slaves.setter
    def meter_slaves(self, value: list[int]) -> None:
        warnings.warn(
            "PlantCapabilities.meter_slaves is deprecated; use meter_addresses",
            DeprecationWarning,
            stacklevel=2,
        )
        self.meter_addresses = value

    @property
    def lv_battery_slaves(self) -> list[int]:
        """Deprecated alias for `lv_battery_addresses`."""
        warnings.warn(
            "PlantCapabilities.lv_battery_slaves is deprecated; use lv_battery_addresses",
            DeprecationWarning,
            stacklevel=2,
        )
        return self.lv_battery_addresses

    @lv_battery_slaves.setter
    def lv_battery_slaves(self, value: list[int]) -> None:
        warnings.warn(
            "PlantCapabilities.lv_battery_slaves is deprecated; use lv_battery_addresses",
            DeprecationWarning,
            stacklevel=2,
        )
        self.lv_battery_addresses = value

    @property
    def bcu_slaves(self) -> list[tuple[int, int]]:
        """Deprecated alias for `bcu_stacks`."""
        warnings.warn(
            "PlantCapabilities.bcu_slaves is deprecated; use bcu_stacks",
            DeprecationWarning,
            stacklevel=2,
        )
        return self.bcu_stacks

    @bcu_slaves.setter
    def bcu_slaves(self, value: list[tuple[int, int]]) -> None:
        warnings.warn(
            "PlantCapabilities.bcu_slaves is deprecated; use bcu_stacks",
            DeprecationWarning,
            stacklevel=2,
        )
        self.bcu_stacks = value

    @property
    def is_hv(self) -> bool:
        """Return True if this system uses HV battery stacks (BCU/BMU) rather than LV packs."""
        return manifest.has_capability("is_hv", self.device_type)

    SCHEMA_VERSION: ClassVar[int] = 1

    def to_dict(self) -> dict[str, Any]:
        """Serialise to a JSON-safe dict for caller-managed persistence.

        Round-trips through from_dict(). Addresses render as `0x..` strings to
        match the form used in logs, exceptions, and code. The schema_version
        field gives future-us an escape hatch for format changes.
        """
        return {
            "schema_version": self.SCHEMA_VERSION,
            "device_type": self.device_type.name,
            "inverter_address": f"0x{self.inverter_address:02x}",
            "meter_addresses": [f"0x{a:02x}" for a in self.meter_addresses],
            "lv_battery_addresses": [f"0x{a:02x}" for a in self.lv_battery_addresses],
            "bcu_stacks": [[offset, modules] for offset, modules in self.bcu_stacks],
            "aio_battery_module_addresses": [f"0x{a:02x}" for a in self.aio_battery_module_addresses],
            "hv_bmu_addresses": [f"0x{a:02x}" for a in self.hv_bmu_addresses],
            "lv_bcu_address": f"0x{self.lv_bcu_address:02x}" if self.lv_bcu_address is not None else None,
            "arm_firmware_version": self.arm_firmware_version,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "PlantCapabilities":
        """Reconstruct from a to_dict() payload.

        Accepts two on-disk shapes:

        - **v2.0.0 (legacy, no `schema_version`)**: `device_type` as the enum
          value (e.g. `"2"`), addresses as raw integers, no `schema_version`
          key. Persisted by the v2.0.0 `to_dict()`.
        - **v2.0.1+ (versioned)**: `schema_version` present and equal to
          `SCHEMA_VERSION`, `device_type` as enum name (e.g. `"HYBRID_GEN1"`),
          addresses as `"0x..."` hex strings.

        A `schema_version` that's present but doesn't match `SCHEMA_VERSION`
        raises `ValueError` — callers can catch and re-run detect() without
        prior. Pre-rename `*_slave(s)` key aliases are normalised silently so
        state persisted under the older conventions still loads cleanly.
        """
        normalised: dict[str, Any] = dict(data)
        for old, new in _CAPABILITIES_LEGACY_ALIASES.items():
            if old in normalised and new not in normalised:
                normalised[new] = normalised.pop(old)
        version = normalised.get("schema_version")
        if version is not None and version != cls.SCHEMA_VERSION:
            raise ValueError(f"unsupported PlantCapabilities schema_version {version!r}; expected {cls.SCHEMA_VERSION}")

        def _addr(v: Any) -> int:
            # Accept either hex strings (the v2.0.1+ canonical form) or raw ints
            # (the v2.0.0 legacy form, or hand-edited payloads).
            return int(v, 0) if isinstance(v, str) else int(v)

        def _device_type(v: Any) -> Model:
            # v2.0.1+ persists the enum name (Model["HYBRID_GEN1"]); v2.0.0
            # persisted the enum value (Model("2")). Try the name lookup first
            # — it's what every payload emitted by 2.0.1's to_dict() uses — and
            # fall back to the value lookup so v2.0.0 payloads keep working.
            # `str(v)` on the fallback handles unquoted ints from sloppy JSON
            # tooling (`device_type: 2` instead of `"2"`).
            if isinstance(v, Model):
                return v
            try:
                return Model[v]
            except (KeyError, TypeError):
                return Model(str(v))

        # `.get(k) or []` (not `.get(k, [])`) so an explicit `null` in JSON for
        # any of the optional list fields safely degrades to empty rather than
        # raising TypeError on iteration.
        return cls(
            device_type=_device_type(normalised["device_type"]),
            inverter_address=_addr(normalised["inverter_address"]),
            meter_addresses=[_addr(a) for a in (normalised.get("meter_addresses") or [])],
            lv_battery_addresses=[_addr(a) for a in (normalised.get("lv_battery_addresses") or [])],
            # Coerce bcu_stacks entries — hand-edited JSON / differently-serialised
            # payloads can put strings here, which would TypeError downstream
            # (`0x70 + offset` in detect()). Fail loud at parse time instead.
            bcu_stacks=[(int(offset), int(modules)) for offset, modules in (normalised.get("bcu_stacks") or [])],
            aio_battery_module_addresses=[_addr(a) for a in (normalised.get("aio_battery_module_addresses") or [])],
            hv_bmu_addresses=[_addr(a) for a in (normalised.get("hv_bmu_addresses") or [])],
            lv_bcu_address=(
                _addr(normalised["lv_bcu_address"]) if normalised.get("lv_bcu_address") is not None else None
            ),
            arm_firmware_version=normalised.get("arm_firmware_version"),
        )

    def __repr__(self) -> str:
        meters = ", ".join(f"0x{a:02x}" for a in self.meter_addresses)
        batts = ", ".join(f"0x{a:02x}" for a in self.lv_battery_addresses)
        bcus = ", ".join(f"({o}, {n})" for o, n in self.bcu_stacks)
        aio_mods = ", ".join(f"0x{a:02x}" for a in self.aio_battery_module_addresses)
        hv_bmus = ", ".join(f"0x{a:02x}" for a in self.hv_bmu_addresses)
        lv_bcu = f"0x{self.lv_bcu_address:02x}" if self.lv_bcu_address is not None else "None"
        return (
            f"PlantCapabilities("
            f"device_type=Model.{self.device_type.name}, "
            f"inverter_address=0x{self.inverter_address:02x}, "
            f"meter_addresses=[{meters}], "
            f"lv_battery_addresses=[{batts}], "
            f"bcu_stacks=[{bcus}], "
            f"aio_battery_module_addresses=[{aio_mods}], "
            f"hv_bmu_addresses=[{hv_bmus}], "
            f"lv_bcu_address={lv_bcu}, "
            f"arm_firmware_version={self.arm_firmware_version})"
        )

    @property
    def is_three_phase(self) -> bool:
        """Return True if this system uses three-phase registers (HR/IR 1000-range)."""
        return manifest.has_capability("is_three_phase", self.device_type)

    @property
    def is_ac_coupled(self) -> bool:
        """Return True if this system is AC-coupled (no integrated DC battery)."""
        return manifest.has_capability("is_ac_coupled", self.device_type)

    @property
    def has_extended_slots(self) -> bool:
        """Return True if this system supports the extended 10-slot map (HR 240–299)."""
        return manifest.has_extended_slots(self.device_type, self.arm_firmware_version)

    @property
    def has_ac_config_block(self) -> bool:
        """Return True if this system exposes the HR(300-359) AC-output config block.

        Covers export priority, EPS enable, AC charge/discharge limits and pause mode —
        present on AC-coupled inverters and the All-in-One, absent (times out) on
        DC-coupled/hybrid models. See `manifest.CAPABILITIES["has_ac_config_block"]` (#162).

        Strictly a register-surface fact: block presence does not mean HR(313/314)
        replaces the DC battery-rate pair HR(111/112) — on the All-in-One the DC pair
        remains the operative battery-rate control (hass#281). Route control decisions
        on `is_ac_coupled` instead (#302).
        """
        return manifest.has_capability("has_ac_config_block", self.device_type)

    @property
    def has_smart_load_block(self) -> bool:
        """Return True if this system exposes a readable HR(540-599) Smart Load block.

        Currently False for every model: no inverter has been confirmed to answer the
        read on real hardware, and HYBRID_GEN1 is confirmed to time out on it. See
        `manifest.CAPABILITIES["has_smart_load_block"]` (#179).
        """
        return manifest.has_capability("has_smart_load_block", self.device_type)

    @property
    def has_hv_cabinet_block(self) -> bool:
        """Return True if this system exposes a readable HR(499-510) HV cabinet topology block.

        Currently False for every model: no inverter has been confirmed to answer the read on
        real hardware. See `manifest.CAPABILITIES["has_hv_cabinet_block"]` (#265).
        """
        return manifest.has_capability("has_hv_cabinet_block", self.device_type)

    @property
    def has_peak_shaving_block(self) -> bool:
        """Return True if this system exposes a readable HR(20000-20051) peak-shaving block.

        Currently False for every model: no inverter has been confirmed to answer the read on
        real hardware. See `manifest.CAPABILITIES["has_peak_shaving_block"]`.
        """
        return manifest.has_capability("has_peak_shaving_block", self.device_type)

    @property
    def is_ems(self) -> bool:
        """Return True if this system is an EMS plant controller (HR/IR 2040-range)."""
        return manifest.has_capability("is_ems", self.device_type)

    @property
    def is_gateway(self) -> bool:
        """Return True if this system is a Gateway (IR 1600-range)."""
        return manifest.has_capability("is_gateway", self.device_type)

bcu_slaves property writable

Deprecated alias for bcu_stacks.

has_ac_config_block property

Return True if this system exposes the HR(300-359) AC-output config block.

Covers export priority, EPS enable, AC charge/discharge limits and pause mode — present on AC-coupled inverters and the All-in-One, absent (times out) on DC-coupled/hybrid models. See manifest.CAPABILITIES["has_ac_config_block"] (#162).

Strictly a register-surface fact: block presence does not mean HR(313/314) replaces the DC battery-rate pair HR(111/112) — on the All-in-One the DC pair remains the operative battery-rate control (hass#281). Route control decisions on is_ac_coupled instead (#302).

has_extended_slots property

Return True if this system supports the extended 10-slot map (HR 240–299).

has_hv_cabinet_block property

Return True if this system exposes a readable HR(499-510) HV cabinet topology block.

Currently False for every model: no inverter has been confirmed to answer the read on real hardware. See manifest.CAPABILITIES["has_hv_cabinet_block"] (#265).

has_peak_shaving_block property

Return True if this system exposes a readable HR(20000-20051) peak-shaving block.

Currently False for every model: no inverter has been confirmed to answer the read on real hardware. See manifest.CAPABILITIES["has_peak_shaving_block"].

has_smart_load_block property

Return True if this system exposes a readable HR(540-599) Smart Load block.

Currently False for every model: no inverter has been confirmed to answer the read on real hardware, and HYBRID_GEN1 is confirmed to time out on it. See manifest.CAPABILITIES["has_smart_load_block"] (#179).

inverter_slave property writable

Deprecated alias for inverter_address.

is_ac_coupled property

Return True if this system is AC-coupled (no integrated DC battery).

is_ems property

Return True if this system is an EMS plant controller (HR/IR 2040-range).

is_gateway property

Return True if this system is a Gateway (IR 1600-range).

is_hv property

Return True if this system uses HV battery stacks (BCU/BMU) rather than LV packs.

is_three_phase property

Return True if this system uses three-phase registers (HR/IR 1000-range).

lv_battery_slaves property writable

Deprecated alias for lv_battery_addresses.

meter_slaves property writable

Deprecated alias for meter_addresses.

from_dict(data) classmethod

Reconstruct from a to_dict() payload.

Accepts two on-disk shapes:

  • v2.0.0 (legacy, no schema_version): device_type as the enum value (e.g. "2"), addresses as raw integers, no schema_version key. Persisted by the v2.0.0 to_dict().
  • v2.0.1+ (versioned): schema_version present and equal to SCHEMA_VERSION, device_type as enum name (e.g. "HYBRID_GEN1"), addresses as "0x..." hex strings.

A schema_version that's present but doesn't match SCHEMA_VERSION raises ValueError — callers can catch and re-run detect() without prior. Pre-rename *_slave(s) key aliases are normalised silently so state persisted under the older conventions still loads cleanly.

Source code in givenergy_modbus/model/plant.py
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "PlantCapabilities":
    """Reconstruct from a to_dict() payload.

    Accepts two on-disk shapes:

    - **v2.0.0 (legacy, no `schema_version`)**: `device_type` as the enum
      value (e.g. `"2"`), addresses as raw integers, no `schema_version`
      key. Persisted by the v2.0.0 `to_dict()`.
    - **v2.0.1+ (versioned)**: `schema_version` present and equal to
      `SCHEMA_VERSION`, `device_type` as enum name (e.g. `"HYBRID_GEN1"`),
      addresses as `"0x..."` hex strings.

    A `schema_version` that's present but doesn't match `SCHEMA_VERSION`
    raises `ValueError` — callers can catch and re-run detect() without
    prior. Pre-rename `*_slave(s)` key aliases are normalised silently so
    state persisted under the older conventions still loads cleanly.
    """
    normalised: dict[str, Any] = dict(data)
    for old, new in _CAPABILITIES_LEGACY_ALIASES.items():
        if old in normalised and new not in normalised:
            normalised[new] = normalised.pop(old)
    version = normalised.get("schema_version")
    if version is not None and version != cls.SCHEMA_VERSION:
        raise ValueError(f"unsupported PlantCapabilities schema_version {version!r}; expected {cls.SCHEMA_VERSION}")

    def _addr(v: Any) -> int:
        # Accept either hex strings (the v2.0.1+ canonical form) or raw ints
        # (the v2.0.0 legacy form, or hand-edited payloads).
        return int(v, 0) if isinstance(v, str) else int(v)

    def _device_type(v: Any) -> Model:
        # v2.0.1+ persists the enum name (Model["HYBRID_GEN1"]); v2.0.0
        # persisted the enum value (Model("2")). Try the name lookup first
        # — it's what every payload emitted by 2.0.1's to_dict() uses — and
        # fall back to the value lookup so v2.0.0 payloads keep working.
        # `str(v)` on the fallback handles unquoted ints from sloppy JSON
        # tooling (`device_type: 2` instead of `"2"`).
        if isinstance(v, Model):
            return v
        try:
            return Model[v]
        except (KeyError, TypeError):
            return Model(str(v))

    # `.get(k) or []` (not `.get(k, [])`) so an explicit `null` in JSON for
    # any of the optional list fields safely degrades to empty rather than
    # raising TypeError on iteration.
    return cls(
        device_type=_device_type(normalised["device_type"]),
        inverter_address=_addr(normalised["inverter_address"]),
        meter_addresses=[_addr(a) for a in (normalised.get("meter_addresses") or [])],
        lv_battery_addresses=[_addr(a) for a in (normalised.get("lv_battery_addresses") or [])],
        # Coerce bcu_stacks entries — hand-edited JSON / differently-serialised
        # payloads can put strings here, which would TypeError downstream
        # (`0x70 + offset` in detect()). Fail loud at parse time instead.
        bcu_stacks=[(int(offset), int(modules)) for offset, modules in (normalised.get("bcu_stacks") or [])],
        aio_battery_module_addresses=[_addr(a) for a in (normalised.get("aio_battery_module_addresses") or [])],
        hv_bmu_addresses=[_addr(a) for a in (normalised.get("hv_bmu_addresses") or [])],
        lv_bcu_address=(
            _addr(normalised["lv_bcu_address"]) if normalised.get("lv_bcu_address") is not None else None
        ),
        arm_firmware_version=normalised.get("arm_firmware_version"),
    )

to_dict()

Serialise to a JSON-safe dict for caller-managed persistence.

Round-trips through from_dict(). Addresses render as 0x.. strings to match the form used in logs, exceptions, and code. The schema_version field gives future-us an escape hatch for format changes.

Source code in givenergy_modbus/model/plant.py
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
def to_dict(self) -> dict[str, Any]:
    """Serialise to a JSON-safe dict for caller-managed persistence.

    Round-trips through from_dict(). Addresses render as `0x..` strings to
    match the form used in logs, exceptions, and code. The schema_version
    field gives future-us an escape hatch for format changes.
    """
    return {
        "schema_version": self.SCHEMA_VERSION,
        "device_type": self.device_type.name,
        "inverter_address": f"0x{self.inverter_address:02x}",
        "meter_addresses": [f"0x{a:02x}" for a in self.meter_addresses],
        "lv_battery_addresses": [f"0x{a:02x}" for a in self.lv_battery_addresses],
        "bcu_stacks": [[offset, modules] for offset, modules in self.bcu_stacks],
        "aio_battery_module_addresses": [f"0x{a:02x}" for a in self.aio_battery_module_addresses],
        "hv_bmu_addresses": [f"0x{a:02x}" for a in self.hv_bmu_addresses],
        "lv_bcu_address": f"0x{self.lv_bcu_address:02x}" if self.lv_bcu_address is not None else None,
        "arm_firmware_version": self.arm_firmware_version,
    }

BatteryCalibrationStage

Bases: int, Enum

Battery calibration stages.

Source code in givenergy_modbus/model/inverter.py
323
324
325
326
327
328
329
330
331
332
333
class BatteryCalibrationStage(int, Enum):
    """Battery calibration stages."""

    OFF = 0
    DISCHARGE = 1
    SET_LOWER_LIMIT = 2
    CHARGE = 3
    SET_UPPER_LIMIT = 4
    BALANCE = 5
    SET_FULL_CAPACITY = 6
    FINISH = 7

BatteryPowerMode

Bases: int, Enum

Battery discharge strategy.

Source code in givenergy_modbus/model/inverter.py
316
317
318
319
320
class BatteryPowerMode(int, Enum):
    """Battery discharge strategy."""

    EXPORT = 0
    SELF_CONSUMPTION = 1

BatteryType

Bases: int, Enum

Installed battery type.

Source code in givenergy_modbus/model/inverter.py
343
344
345
346
347
class BatteryType(int, Enum):
    """Installed battery type."""

    LEAD_ACID = 0
    LITHIUM = 1

Certification

Bases: IntEnum

Grid compliance certification.

Source code in givenergy_modbus/model/inverter.py
386
387
388
389
390
391
392
393
394
395
396
397
class Certification(IntEnum):
    """Grid compliance certification."""

    UNKNOWN = 0
    G98 = 8
    G99 = 12
    G98_NI = 16
    G99_NI = 17

    @classmethod
    def _missing_(cls, value):
        return cls.UNKNOWN

ChargeStatus

Bases: IntEnum

Known charge-status codes observed on single-phase inverters (IR(14), #222).

Raw int accessible via charge_status; typed label via charge_status_label. Unknown codes decode to None via charge_status_label rather than raising.

Source code in givenergy_modbus/model/inverter.py
450
451
452
453
454
455
456
457
458
459
460
class ChargeStatus(IntEnum):
    """Known charge-status codes observed on single-phase inverters (IR(14), #222).

    Raw int accessible via `charge_status`; typed label via `charge_status_label`.
    Unknown codes decode to ``None`` via `charge_status_label` rather than raising.
    """

    IDLE = 0
    CHARGING = 2
    FINISHING = 3
    DISCHARGING = 5

Generation

Bases: StrEnum

Inverter hardware generation.

Source code in givenergy_modbus/model/inverter.py
413
414
415
416
417
418
419
420
421
422
class Generation(StrEnum):
    """Inverter hardware generation."""

    GEN1 = "Gen 1"
    GEN2 = "Gen 2"
    GEN3 = "Gen 3"
    GEN3_PLUS = "Gen 3+"
    GEN4 = "Gen 4"
    AIO2 = "AIO 2"
    UNKNOWN = "Unknown"

InverterType

Bases: IntEnum

Inverter phase and voltage type.

Source code in givenergy_modbus/model/inverter.py
400
401
402
403
404
405
406
407
408
409
410
class InverterType(IntEnum):
    """Inverter phase and voltage type."""

    SINGLE_PHASE_LV = 0
    SINGLE_PHASE_HV = 1
    THREE_PHASE_LV = 2
    THREE_PHASE_HV = 3

    @classmethod
    def _missing_(cls, value):
        return cls.SINGLE_PHASE_LV

MeterType

Bases: int, Enum

Installed meter type.

Source code in givenergy_modbus/model/inverter.py
336
337
338
339
340
class MeterType(int, Enum):
    """Installed meter type."""

    CT_OR_EM418 = 0
    EM115 = 1

Model

Bases: str, Enum

Known models of inverters.

Single-digit values are the coarse family (first digit of the DTC); these are what Model(dtc_string) returns via _missing_ for backward compatibility. Two-character and "20gN" values are more specific variants reachable via resolve_model(raw_dtc, arm_fw) or by direct construction e.g. Model("81").

Note: Gen 2 inverters with an 'EA' serial prefix were previously mapped via a serial-prefix lookup table (removed in 1.0). Their device type code first digit is unknown — if EA-prefix units report a code not listed here, missing will raise ValueError. A field report from a Gen 2 owner is needed to add support.

Source code in givenergy_modbus/model/inverter.py
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
class Model(str, Enum):
    """Known models of inverters.

    Single-digit values are the coarse family (first digit of the DTC); these are
    what `Model(dtc_string)` returns via `_missing_` for backward compatibility.
    Two-character and "20gN" values are more specific variants reachable via
    `resolve_model(raw_dtc, arm_fw)` or by direct construction e.g. `Model("81")`.

    Note: Gen 2 inverters with an 'EA' serial prefix were previously mapped via a
    serial-prefix lookup table (removed in 1.0). Their device type code first digit
    is unknown — if EA-prefix units report a code not listed here, _missing_ will
    raise ValueError. A field report from a Gen 2 owner is needed to add support.
    """

    # Coarse families (first digit of DTC) — backward-compatible values
    HYBRID = "2"
    AC = "3"
    HYBRID_3PH = "4"
    EMS = "5"
    AC_3PH = "6"
    GATEWAY = "7"
    ALL_IN_ONE = "8"

    # Specific variants (two-digit DTC prefix or firmware-disambiguated)
    HYBRID_GEN1 = "20g1"
    HYBRID_GEN2 = "20g2"
    HYBRID_GEN3 = "20g3"
    POLAR = "21"
    AIO_COMMERCIAL = "41"
    EMS_COMMERCIAL = "51"
    HYBRID_HV_GEN3 = "81"
    ALL_IN_ONE_HYBRID = "82"
    HYBRID_GEN4 = "83"

    @classmethod
    def _missing_(cls, value):
        """Pick model from the first digit of the device type code."""
        if not isinstance(value, str) or len(value) <= 1:
            return None
        return cls(value[0])

    @property
    def system_battery_voltage(self) -> float:
        """Represent nominal battery voltage for this system."""
        if self.value == Model.ALL_IN_ONE:
            return 307.0
        elif self.value in [Model.HYBRID_3PH, Model.AC_3PH]:
            return 76.8
        else:
            return 51.2

system_battery_voltage property

Represent nominal battery voltage for this system.

Phase

Bases: IntEnum

Number of AC phases.

Source code in givenergy_modbus/model/inverter.py
436
437
438
439
440
441
442
443
444
445
446
447
class Phase(IntEnum):
    """Number of AC phases."""

    ONE = 1
    THREE = 3

    @classmethod
    def _missing_(cls, value):
        """Accept a DTC string and map its first digit to a phase count."""
        if isinstance(value, str) and value[:1] in _DTC_PREFIX_TO_PHASE:
            return cls(_DTC_PREFIX_TO_PHASE[value[:1]])
        return None

PowerFactorFunctionModel

Bases: int, Enum

Power Factor function model.

Source code in givenergy_modbus/model/inverter.py
350
351
352
353
354
355
356
357
358
359
class PowerFactorFunctionModel(int, Enum):
    """Power Factor function model."""

    PF_1 = 0
    PF_BY_SET = 1
    DEFAULT_PF_LINE = 2
    USER_PF_LINE = 3
    UNDER_EXCITED_INDUCTIVE_REACTIVE_POWER = 4
    OVER_EXCITED_CAPACITIVE_REACTIVE_POWER = 5
    QV_MODEL = 6

SinglePhaseInverter

Bases: _SinglePhaseInverterBase, _InverterCommands, RegisterMetadataMixin

GivEnergy single-phase inverter data model.

Composes the _InverterCommands mixin so consumers can call inverter.set_*(...) directly instead of routing through givenergy_modbus.client.commands.*. The mixin reads self.slot_map so slot setters no longer need it threaded through by callers. Model-specific command mixins (three-phase, EMS, pause-mode) will compose in additively in later 2.x minors — see #75.

Source code in givenergy_modbus/model/inverter.py
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
class SinglePhaseInverter(  # type: ignore[valid-type,misc]
    _SinglePhaseInverterBase, _InverterCommands, RegisterMetadataMixin
):
    """GivEnergy single-phase inverter data model.

    Composes the `_InverterCommands` mixin so consumers can call
    `inverter.set_*(...)` directly instead of routing through
    `givenergy_modbus.client.commands.*`. The mixin reads `self.slot_map` so
    slot setters no longer need it threaded through by callers. Model-specific
    command mixins (three-phase, EMS, pause-mode) will compose in additively
    in later 2.x minors — see #75.
    """

    REGISTER_GETTER: ClassVar[type[RegisterGetter]] = SinglePhaseInverterRegisterGetter

    @classmethod
    def from_register_cache(cls, register_cache) -> "SinglePhaseInverter":
        """Construct a SinglePhaseInverter from a RegisterCache."""
        return cls.model_validate(SinglePhaseInverterRegisterGetter(register_cache).build())

    def p_pv(self) -> int | None:
        """Computes the total PV power, or None if either input is unavailable."""
        if self.p_pv1 is None or self.p_pv2 is None:  # type: ignore[attr-defined]
            return None
        return self.p_pv1 + self.p_pv2  # type: ignore[attr-defined]

    def e_pv_day(self) -> float | None:
        """Computes the total PV energy for the day, or None if either input is unavailable."""
        if self.e_pv1_day is None or self.e_pv2_day is None:  # type: ignore[attr-defined]
            return None
        return self.e_pv1_day + self.e_pv2_day  # type: ignore[attr-defined]

    # Accurate per-model identity for IR(44)/IR(45-46) on AC/AIO (#293): the inverter's
    # battery-discharge AC output. Populated ONLY by _apply_field_identity below; on
    # DC hybrids these stay None and e_pv_generation_* carries the (genuine) PV values.
    e_inverter_out_today: float | None = None
    e_inverter_out_total: float | None = None

    @model_validator(mode="before")
    @classmethod
    def _apply_field_identity(cls, values: dict) -> dict:
        """Route model-dependent register identities per the manifest (#293).

        Runs on the raw values dict (the model is frozen, so after-mode mutation is
        unavailable). Overrides-only: unresolvable models keep the status quo.
        Input-driven, so re-validation is idempotent: a value moves only when the
        source field actually carries one, and re-validating a dumped model (where
        e_pv_generation_* is already None) passes the moved values through untouched.
        """
        from givenergy_modbus.model.manifest import ir44_is_inverter_output, pv_string_vi_is_ac_derived

        if not isinstance(values, dict):
            return values
        dtc = values.get("device_type_code")
        arm_fw = values.get("arm_firmware_version")
        if dtc is None or arm_fw is None:
            return values
        try:
            model = resolve_model(int(dtc, 16), int(arm_fw))
        except (TypeError, ValueError):
            return values
        if ir44_is_inverter_output(model, arm_fw=int(arm_fw)):
            for src, dst in (
                ("e_pv_generation_today", "e_inverter_out_today"),
                ("e_pv_generation_total", "e_inverter_out_total"),
            ):
                if values.get(src) is not None:
                    values[dst] = values.pop(src)
                    values[src] = None
        if pv_string_vi_is_ac_derived(model, arm_fw=int(arm_fw)):
            # Suppressed rather than relocated: v_ac1 already carries the voltage these
            # registers echo, so there is no destination field worth adding. The power
            # and energy members of the PV family are left alone — they are the only
            # generation figure an AC-metered install has (hass#281).
            for field in ("v_pv1", "v_pv2", "i_pv1", "i_pv2"):
                values[field] = None
        return values

    def _battery_energy(self, direction: str, metric: str) -> float | None:
        """Route a battery-energy metric to this model's authoritative alt register.

        Which register location a firmware populates is a *static* property of the
        model, not something to infer from live values (the #119 lesson — see #76 /
        Codex review on #150). `manifest.VALUE_SOURCES` declares, per specific model,
        which `altN` source is authoritative for each metric; the value is returned
        verbatim including a legitimate 0.0. Returns None when the model or metric is
        undeclared (honest "no evidence yet") — no value inspection, no cross-source
        fallback. Resolves the *specific* model the way `slot_map` does: `self.model`
        decodes HR(0) alone and only yields the coarse family (e.g. HYBRID, not
        HYBRID_GEN1), which would miss the models we special-case.
        """
        dtc = self.device_type_code  # type: ignore[attr-defined]
        arm_fw = self.arm_firmware_version  # type: ignore[attr-defined]
        if dtc is None or arm_fw is None:
            return None
        model = resolve_model(int(dtc, 16), int(arm_fw))
        from givenergy_modbus.model.manifest import battery_energy_source

        alt = battery_energy_source(model, metric, arm_fw=int(arm_fw))
        if alt is None:
            return None
        return getattr(self, f"e_battery_{direction}_{metric}_{alt}")

    @computed_field  # type: ignore[prop-decorator]
    @property
    def e_battery_charge_today(self) -> float | None:
        """Canonical daily battery charge energy (kWh), routed by model (see #76)."""
        return self._battery_energy("charge", "today")

    @computed_field  # type: ignore[prop-decorator]
    @property
    def e_battery_discharge_today(self) -> float | None:
        """Canonical daily battery discharge energy (kWh), routed by model (see #76)."""
        return self._battery_energy("discharge", "today")

    @computed_field  # type: ignore[prop-decorator]
    @property
    def e_battery_charge_total(self) -> float | None:
        """Canonical total battery charge energy (kWh), routed by model (see #76)."""
        return self._battery_energy("charge", "total")

    @computed_field  # type: ignore[prop-decorator]
    @property
    def e_battery_discharge_total(self) -> float | None:
        """Canonical total battery discharge energy (kWh), routed by model (see #76)."""
        return self._battery_energy("discharge", "total")

    @computed_field  # type: ignore[prop-decorator]
    @property
    def grid_import_power(self) -> int | None:
        """Non-negative grid import power (W); zero when exporting or idle (#205)."""
        if self.p_grid_out is None:  # type: ignore[attr-defined]
            return None
        return max(0, -self.p_grid_out)  # type: ignore[attr-defined]

    @computed_field  # type: ignore[prop-decorator]
    @property
    def grid_export_power(self) -> int | None:
        """Non-negative grid export power (W); zero when importing or idle (#205)."""
        if self.p_grid_out is None:  # type: ignore[attr-defined]
            return None
        return max(0, self.p_grid_out)  # type: ignore[attr-defined]

    @computed_field  # type: ignore[prop-decorator]
    @property
    def battery_charge_power(self) -> int | None:
        """Non-negative battery charge power (W); zero when discharging or idle (#205)."""
        if self.p_battery is None:  # type: ignore[attr-defined]
            return None
        return max(0, -self.p_battery)  # type: ignore[attr-defined]

    @computed_field  # type: ignore[prop-decorator]
    @property
    def battery_discharge_power(self) -> int | None:
        """Non-negative battery discharge power (W); zero when charging or idle (#205)."""
        if self.p_battery is None:  # type: ignore[attr-defined]
            return None
        return max(0, self.p_battery)  # type: ignore[attr-defined]

    @property
    def slot_map(self) -> SlotMap:
        """Register address pairs for the charge/discharge time slots on this model."""
        from givenergy_modbus.model.manifest import has_extended_slots

        dtc = self.device_type_code  # type: ignore[attr-defined]
        arm_fw = self.arm_firmware_version  # type: ignore[attr-defined]
        if dtc is None or arm_fw is None:
            return SINGLE_PHASE_SLOTS
        model = resolve_model(int(dtc, 16), int(arm_fw))
        if has_extended_slots(model, int(arm_fw)):
            return EXTENDED_SLOTS
        return SINGLE_PHASE_SLOTS

    @computed_field  # type: ignore[prop-decorator]
    @property
    def battery_capacity_kwh(self) -> float | None:
        """Returns the nominal battery capacity in kWh, derived from Ah and model voltage."""
        if self.battery_capacity_ah is None or self.model is None:  # type: ignore[attr-defined]
            return None
        return self.battery_capacity_ah * self.model.system_battery_voltage / 1000  # type: ignore[attr-defined]

    @computed_field  # type: ignore[prop-decorator]
    @property
    def battery_max_power(self) -> int | None:
        """Returns the rated battery charge/discharge power in watts, derived from model and firmware."""
        return _battery_max_power(self.device_type_code, self.arm_firmware_version)  # type: ignore[attr-defined]

    @computed_field  # type: ignore[prop-decorator]
    @property
    def inverter_max_power(self) -> int | None:
        """Returns the rated inverter power in watts, derived from the device type code."""
        return _DTC_RATED_POWER.get(self.device_type_code)  # type: ignore[attr-defined]

    @computed_field  # type: ignore[prop-decorator]
    @property
    def is_ac_coupled(self) -> bool:
        """True for AC-coupled inverters (no integrated DC battery).

        Coarse-family resolution is correct here: AC DTCs resolve to Model.AC /
        Model.AC_3PH and neither has specific sub-variants. False when the model is
        unknown (DTC unread).
        """
        from givenergy_modbus.model.manifest import has_capability

        return has_capability("is_ac_coupled", self.model)  # type: ignore[attr-defined]

    @computed_field  # type: ignore[prop-decorator]
    @property
    def e_consumption_today(self) -> float | None:
        """House consumption today (kWh), matching the GE app's "Consumption today".

        DERIVED, not metered: single-phase units expose no consumption register, so the
        GE app computes this value. Sentinel cross-correlation against the app's
        Energy-today screen (#174) recovered the exact formula:

            consumption = pv_generation + grid_import − grid_export − ac_charge

        Battery DC charge/discharge throughput nets out and is not a term. The result
        carries the same conversion-loss bias the app shows (energy balance overshoots
        real consumption by a few %). This is the GE-universe definition of
        "consumption" specifically — other plant equipment may define it differently.

        Three-phase units have a native e_load_today register and so do NOT get this
        computed field (it lives on SinglePhaseInverter only, not in the register LUT).

        On AC-coupled and All-in-One units this returns None: manifest.py's IR44
        identity routing (#293) already makes e_pv_generation_today None there (the
        register carries inverter output, not PV, on those models), so the pv term's
        own None-propagation below does the rest — no separate AC/AIO guard needed. A
        candidate corrected-AIO consumption formula was evaluated against wire
        evidence and failed the evidence gate; see manifest.py's module comment.

        Returns None if any input is unavailable.
        """
        pv = self.e_pv_generation_today  # type: ignore[attr-defined]
        grid_in = self.e_grid_in_day  # type: ignore[attr-defined]
        grid_out = self.e_grid_out_day  # type: ignore[attr-defined]
        ac_charge = self.e_ac_charge_today  # type: ignore[attr-defined]
        if None in (pv, grid_in, grid_out, ac_charge):
            return None
        return pv + grid_in - grid_out - ac_charge

    @computed_field  # type: ignore[prop-decorator]
    @property
    def e_self_consumption_today(self) -> float | None:
        """Self-consumption energy today (kWh): PV generation used on-site (GivTCP parity).

        DERIVED: PV generation today − grid export today, clamped at ≥ 0.

        Two limitations to be aware of:
        - Battery-to-grid export is counted in grid_out, so this slightly under-counts
          true self-consumption when the battery discharges to grid. GivTCP accepts this
          approximation; we match it here.
        - NOT monotonically increasing: when battery exports to grid, grid_out rises
          without pv_generation rising, causing the difference to dip. Consumers that
          use state_class TOTAL_INCREASING (e.g. HA energy dashboard) MUST apply their
          own monotonic clamp rather than consuming the raw value directly.

        On AC-coupled and All-in-One units this returns None via the same
        None-propagation as e_consumption_today: manifest.py's IR44 identity routing
        (#293) already makes e_pv_generation_today None there.

        Returns None if any input is unavailable.
        """
        pv = self.e_pv_generation_today  # type: ignore[attr-defined]
        grid_out = self.e_grid_out_day  # type: ignore[attr-defined]
        if None in (pv, grid_out):
            return None
        return max(0.0, pv - grid_out)

    @computed_field  # type: ignore[prop-decorator]
    @property
    def e_self_consumption_total(self) -> float | None:
        """Lifetime self-consumption energy (kWh): PV generation used on-site (GivTCP parity).

        DERIVED: PV generation total − grid export total, clamped at ≥ 0.

        Same battery-to-grid under-count and non-monotonicity caveats as
        e_self_consumption_today: the difference of two running counters is not
        guaranteed to increase monotonically. Consumers using state_class
        TOTAL_INCREASING MUST apply a monotonic clamp (e.g. HA's monotonic=True
        sensor path) rather than consuming the raw value directly.

        On AC-coupled and All-in-One units this returns None: manifest.py's IR44
        identity routing (#293) already makes e_pv_generation_total None there.

        Returns None if any input is unavailable.
        """
        pv = self.e_pv_generation_total  # type: ignore[attr-defined]
        grid_out = self.e_grid_out_total  # type: ignore[attr-defined]
        if None in (pv, grid_out):
            return None
        return max(0.0, pv - grid_out)

    @computed_field  # type: ignore[prop-decorator]
    @property
    def e_pv_direct_today(self) -> float | None:
        """PV energy used directly by load today (kWh): solar bypassing battery and grid.

        DERIVED: (pv_generation − grid_export) − pv_to_battery, clamped to [0, pv − grid_export],
        where pv_to_battery = max(0, battery_charge − ac_charge).

        e_battery_charge lumps PV-sourced and AC-sourced charge into one counter, so the
        PV portion that went to the battery is battery_charge − ac_charge — subtracting it
        leaves the PV that reached load directly. Without the ac_charge term the figure
        would under-count by the day's AC-charge energy on time-of-use tariffs (Octopus Go
        / Predbat); for pure-solar systems ac_charge is 0 and it's a no-op.

        Both bounds matter. The lower clamp (≥ 0) handles export exceeding PV. The upper
        clamp (≤ pv − grid_export, via flooring pv_to_battery at 0) handles AC→DC
        conversion loss / counter skew making ac_charge momentarily exceed battery_charge:
        without it, direct PV could exceed total on-site PV self-consumption, which is
        nonsensical (a subset can't beat its superset) — Codex review on #313.

        Effectively restricted to DC-coupled solar hybrids: on AC-coupled and All-in-One
        units the PV-generation registers (IR44/45-46) are mislabelled — they carry the
        inverter's battery-discharge AC output, not PV (#293) — and manifest.py's IR44
        identity routing already makes e_pv_generation_today None there, so the pv term's
        None-propagation below returns None with no separate AC/AIO guard needed. A
        candidate corrected-AIO formula was evaluated against wire evidence and failed
        the evidence gate; see manifest.py's module comment. In practice
        e_battery_charge_today only routes on HYBRID_GEN1 today (see
        manifest.VALUE_SOURCES / #184), so the field is currently GEN1-effective and
        returns None on other DC models until that map widens.

        Only daily is offered: the lifetime equivalent needs e_ac_charge_total, which
        single-phase firmware does not expose (three-phase only, IR1378/9).

        NOT monotonically increasing intraday — a grid-export burst can dip it — so
        consumers using state_class TOTAL_INCREASING MUST apply their own monotonic clamp
        (e.g. HA's monotonic=True path). Returns None if any input is unavailable or the
        pv term is unavailable for this model (AC/AIO).
        """
        pv = self.e_pv_generation_today  # type: ignore[attr-defined]
        grid_out = self.e_grid_out_day  # type: ignore[attr-defined]
        battery_charge = self.e_battery_charge_today
        ac_charge = self.e_ac_charge_today  # type: ignore[attr-defined]
        if None in (pv, grid_out, battery_charge, ac_charge):
            return None
        # PV that charged the battery, floored at 0: AC→DC conversion loss / counter skew
        # can push ac_charge above battery_charge, which would otherwise make this negative
        # and inflate direct PV beyond total on-site PV. Round to the inputs' native 0.1 kWh
        # resolution — a deci-scaled difference accrues float noise that 1dp removes losslessly.
        pv_to_battery = max(0.0, battery_charge - ac_charge)
        return round(max(0.0, pv - grid_out - pv_to_battery), 1)

    # Plain @property (not @computed_field) so the deprecated alias doesn't
    # appear in model_dump() output. See #84 — renamed to work_time_total_hours
    # to put the unit at the call site.
    @property
    def work_time_total(self) -> int | None:
        """Deprecated alias for `work_time_total_hours`."""
        warnings.warn(
            "SinglePhaseInverter.work_time_total is deprecated; use work_time_total_hours",
            DeprecationWarning,
            stacklevel=2,
        )
        return self.work_time_total_hours  # type: ignore[attr-defined,no-any-return]

    # Plain @property so the deprecated alias doesn't appear in model_dump().
    # IR(39)/IR(40) were decoded as one uint32 "fault_code"; the installer app names them as
    # two distinct words (IR39 fault, IR40 warning), now split into inverter_fault_code /
    # inverter_warning_code. This alias recombines them into the old 8-hex form for a release.
    @property
    def fault_code(self) -> str | None:
        """Deprecated alias for `inverter_fault_code` + `inverter_warning_code` (recombined)."""
        warnings.warn(
            "SinglePhaseInverter.fault_code is deprecated; use inverter_fault_code "
            "(IR39) and inverter_warning_code (IR40)",
            DeprecationWarning,
            stacklevel=2,
        )
        fault = self.inverter_fault_code  # type: ignore[attr-defined]
        warning = self.inverter_warning_code  # type: ignore[attr-defined]
        if fault is None or warning is None:
            return None
        return f"{fault}{warning}"

    # Plain @property so the deprecated alias doesn't appear in model_dump().
    # HR(199) was decoded as enable_standard_self_consumption_logic (GivTCP-era guess);
    # the GE app names it "Enable Inverter Parallel Mode". The new field name is
    # enable_inverter_parallel_mode; this alias preserves back-compat for existing
    # consumers. Hass: keep your current entity using this property and fold the
    # entity-ID rename into the one-shot #106 migration — do NOT migrate now.
    @property
    def enable_standard_self_consumption_logic(self) -> bool | None:
        """Deprecated alias for `enable_inverter_parallel_mode`."""
        warnings.warn(
            "SinglePhaseInverter.enable_standard_self_consumption_logic is deprecated; "
            "use enable_inverter_parallel_mode",
            DeprecationWarning,
            stacklevel=2,
        )
        return self.enable_inverter_parallel_mode  # type: ignore[attr-defined,no-any-return]

    @property
    def e_inverter_out_day(self) -> float | None:
        """Deprecated alias for `e_inverter_out_today` (AC/AIO) or `e_pv_generation_today` (hybrids, #293)."""
        warnings.warn(
            "SinglePhaseInverter.e_inverter_out_day is deprecated; use e_inverter_out_today "
            "(AC/AIO) or e_pv_generation_today (DC hybrids)",
            DeprecationWarning,
            stacklevel=2,
        )
        if self.e_inverter_out_today is not None:
            return self.e_inverter_out_today
        return self.e_pv_generation_today  # type: ignore[attr-defined]

    # HR(63-82) renamed: _1/_2/_3 → _trip/_reconnect/_grid (installer-confirmed band structure).
    # Aliases preserve back-compat for a release.

    @property
    def v_ac_low_limit_1(self) -> float | None:
        """Deprecated alias for `v_ac_low_limit_trip`."""
        warnings.warn(
            "SinglePhaseInverter.v_ac_low_limit_1 is deprecated; use v_ac_low_limit_trip",
            DeprecationWarning,
            stacklevel=2,
        )
        return self.v_ac_low_limit_trip  # type: ignore[attr-defined,no-any-return]

    @property
    def v_ac_high_limit_1(self) -> float | None:
        """Deprecated alias for `v_ac_high_limit_trip`."""
        warnings.warn(
            "SinglePhaseInverter.v_ac_high_limit_1 is deprecated; use v_ac_high_limit_trip",
            DeprecationWarning,
            stacklevel=2,
        )
        return self.v_ac_high_limit_trip  # type: ignore[attr-defined,no-any-return]

    @property
    def f_ac_low_limit_1(self) -> float | None:
        """Deprecated alias for `f_ac_low_limit_trip`."""
        warnings.warn(
            "SinglePhaseInverter.f_ac_low_limit_1 is deprecated; use f_ac_low_limit_trip",
            DeprecationWarning,
            stacklevel=2,
        )
        return self.f_ac_low_limit_trip  # type: ignore[attr-defined,no-any-return]

    @property
    def f_ac_high_limit_1(self) -> float | None:
        """Deprecated alias for `f_ac_high_limit_trip`."""
        warnings.warn(
            "SinglePhaseInverter.f_ac_high_limit_1 is deprecated; use f_ac_high_limit_trip",
            DeprecationWarning,
            stacklevel=2,
        )
        return self.f_ac_high_limit_trip  # type: ignore[attr-defined,no-any-return]

    @property
    def t_ac_low_voltage_1(self) -> float | None:
        """Deprecated alias for `t_ac_low_voltage_trip`."""
        warnings.warn(
            "SinglePhaseInverter.t_ac_low_voltage_1 is deprecated; use t_ac_low_voltage_trip",
            DeprecationWarning,
            stacklevel=2,
        )
        return self.t_ac_low_voltage_trip  # type: ignore[attr-defined,no-any-return]

    @property
    def t_ac_high_voltage_1(self) -> float | None:
        """Deprecated alias for `t_ac_high_voltage_trip`."""
        warnings.warn(
            "SinglePhaseInverter.t_ac_high_voltage_1 is deprecated; use t_ac_high_voltage_trip",
            DeprecationWarning,
            stacklevel=2,
        )
        return self.t_ac_high_voltage_trip  # type: ignore[attr-defined,no-any-return]

    @property
    def t_ac_low_freq_1(self) -> float | None:
        """Deprecated alias for `t_ac_low_freq_trip`."""
        warnings.warn(
            "SinglePhaseInverter.t_ac_low_freq_1 is deprecated; use t_ac_low_freq_trip",
            DeprecationWarning,
            stacklevel=2,
        )
        return self.t_ac_low_freq_trip  # type: ignore[attr-defined,no-any-return]

    @property
    def t_ac_high_freq_1(self) -> float | None:
        """Deprecated alias for `t_ac_high_freq_trip`."""
        warnings.warn(
            "SinglePhaseInverter.t_ac_high_freq_1 is deprecated; use t_ac_high_freq_trip",
            DeprecationWarning,
            stacklevel=2,
        )
        return self.t_ac_high_freq_trip  # type: ignore[attr-defined,no-any-return]

    @property
    def v_ac_low_limit_2(self) -> float | None:
        """Deprecated alias for `v_ac_low_limit_reconnect`."""
        warnings.warn(
            "SinglePhaseInverter.v_ac_low_limit_2 is deprecated; use v_ac_low_limit_reconnect",
            DeprecationWarning,
            stacklevel=2,
        )
        return self.v_ac_low_limit_reconnect  # type: ignore[attr-defined,no-any-return]

    @property
    def v_ac_high_limit_2(self) -> float | None:
        """Deprecated alias for `v_ac_high_limit_reconnect`."""
        warnings.warn(
            "SinglePhaseInverter.v_ac_high_limit_2 is deprecated; use v_ac_high_limit_reconnect",
            DeprecationWarning,
            stacklevel=2,
        )
        return self.v_ac_high_limit_reconnect  # type: ignore[attr-defined,no-any-return]

    @property
    def f_ac_low_limit_2(self) -> float | None:
        """Deprecated alias for `f_ac_low_limit_reconnect`."""
        warnings.warn(
            "SinglePhaseInverter.f_ac_low_limit_2 is deprecated; use f_ac_low_limit_reconnect",
            DeprecationWarning,
            stacklevel=2,
        )
        return self.f_ac_low_limit_reconnect  # type: ignore[attr-defined,no-any-return]

    @property
    def f_ac_high_limit_2(self) -> float | None:
        """Deprecated alias for `f_ac_high_limit_reconnect`."""
        warnings.warn(
            "SinglePhaseInverter.f_ac_high_limit_2 is deprecated; use f_ac_high_limit_reconnect",
            DeprecationWarning,
            stacklevel=2,
        )
        return self.f_ac_high_limit_reconnect  # type: ignore[attr-defined,no-any-return]

    @property
    def t_ac_low_voltage_2(self) -> float | None:
        """Deprecated alias for `t_ac_low_voltage_reconnect`."""
        warnings.warn(
            "SinglePhaseInverter.t_ac_low_voltage_2 is deprecated; use t_ac_low_voltage_reconnect",
            DeprecationWarning,
            stacklevel=2,
        )
        return self.t_ac_low_voltage_reconnect  # type: ignore[attr-defined,no-any-return]

    @property
    def t_ac_high_voltage_2(self) -> float | None:
        """Deprecated alias for `t_ac_high_voltage_reconnect`."""
        warnings.warn(
            "SinglePhaseInverter.t_ac_high_voltage_2 is deprecated; use t_ac_high_voltage_reconnect",
            DeprecationWarning,
            stacklevel=2,
        )
        return self.t_ac_high_voltage_reconnect  # type: ignore[attr-defined,no-any-return]

    @property
    def t_ac_low_freq_2(self) -> float | None:
        """Deprecated alias for `t_ac_low_freq_reconnect`."""
        warnings.warn(
            "SinglePhaseInverter.t_ac_low_freq_2 is deprecated; use t_ac_low_freq_reconnect",
            DeprecationWarning,
            stacklevel=2,
        )
        return self.t_ac_low_freq_reconnect  # type: ignore[attr-defined,no-any-return]

    @property
    def t_ac_high_freq_2(self) -> float | None:
        """Deprecated alias for `t_ac_high_freq_reconnect`."""
        warnings.warn(
            "SinglePhaseInverter.t_ac_high_freq_2 is deprecated; use t_ac_high_freq_reconnect",
            DeprecationWarning,
            stacklevel=2,
        )
        return self.t_ac_high_freq_reconnect  # type: ignore[attr-defined,no-any-return]

    @property
    def v_ac_low_limit_3(self) -> float | None:
        """Deprecated alias for `v_ac_low_limit_grid`."""
        warnings.warn(
            "SinglePhaseInverter.v_ac_low_limit_3 is deprecated; use v_ac_low_limit_grid",
            DeprecationWarning,
            stacklevel=2,
        )
        return self.v_ac_low_limit_grid  # type: ignore[attr-defined,no-any-return]

    @property
    def v_ac_high_limit_3(self) -> float | None:
        """Deprecated alias for `v_ac_high_limit_grid`."""
        warnings.warn(
            "SinglePhaseInverter.v_ac_high_limit_3 is deprecated; use v_ac_high_limit_grid",
            DeprecationWarning,
            stacklevel=2,
        )
        return self.v_ac_high_limit_grid  # type: ignore[attr-defined,no-any-return]

    @property
    def f_ac_low_limit_3(self) -> float | None:
        """Deprecated alias for `f_ac_low_limit_grid`."""
        warnings.warn(
            "SinglePhaseInverter.f_ac_low_limit_3 is deprecated; use f_ac_low_limit_grid",
            DeprecationWarning,
            stacklevel=2,
        )
        return self.f_ac_low_limit_grid  # type: ignore[attr-defined,no-any-return]

    @property
    def f_ac_high_limit_3(self) -> float | None:
        """Deprecated alias for `f_ac_high_limit_grid`."""
        warnings.warn(
            "SinglePhaseInverter.f_ac_high_limit_3 is deprecated; use f_ac_high_limit_grid",
            DeprecationWarning,
            stacklevel=2,
        )
        return self.f_ac_high_limit_grid  # type: ignore[attr-defined,no-any-return]

battery_capacity_kwh property

Returns the nominal battery capacity in kWh, derived from Ah and model voltage.

battery_charge_power property

Non-negative battery charge power (W); zero when discharging or idle (#205).

battery_discharge_power property

Non-negative battery discharge power (W); zero when charging or idle (#205).

battery_max_power property

Returns the rated battery charge/discharge power in watts, derived from model and firmware.

e_battery_charge_today property

Canonical daily battery charge energy (kWh), routed by model (see #76).

e_battery_charge_total property

Canonical total battery charge energy (kWh), routed by model (see #76).

e_battery_discharge_today property

Canonical daily battery discharge energy (kWh), routed by model (see #76).

e_battery_discharge_total property

Canonical total battery discharge energy (kWh), routed by model (see #76).

e_consumption_today property

House consumption today (kWh), matching the GE app's "Consumption today".

DERIVED, not metered: single-phase units expose no consumption register, so the GE app computes this value. Sentinel cross-correlation against the app's Energy-today screen (#174) recovered the exact formula:

consumption = pv_generation + grid_import − grid_export − ac_charge

Battery DC charge/discharge throughput nets out and is not a term. The result carries the same conversion-loss bias the app shows (energy balance overshoots real consumption by a few %). This is the GE-universe definition of "consumption" specifically — other plant equipment may define it differently.

Three-phase units have a native e_load_today register and so do NOT get this computed field (it lives on SinglePhaseInverter only, not in the register LUT).

On AC-coupled and All-in-One units this returns None: manifest.py's IR44 identity routing (#293) already makes e_pv_generation_today None there (the register carries inverter output, not PV, on those models), so the pv term's own None-propagation below does the rest — no separate AC/AIO guard needed. A candidate corrected-AIO consumption formula was evaluated against wire evidence and failed the evidence gate; see manifest.py's module comment.

Returns None if any input is unavailable.

e_inverter_out_day property

Deprecated alias for e_inverter_out_today (AC/AIO) or e_pv_generation_today (hybrids, #293).

e_pv_direct_today property

PV energy used directly by load today (kWh): solar bypassing battery and grid.

DERIVED: (pv_generation − grid_export) − pv_to_battery, clamped to [0, pv − grid_export], where pv_to_battery = max(0, battery_charge − ac_charge).

e_battery_charge lumps PV-sourced and AC-sourced charge into one counter, so the PV portion that went to the battery is battery_charge − ac_charge — subtracting it leaves the PV that reached load directly. Without the ac_charge term the figure would under-count by the day's AC-charge energy on time-of-use tariffs (Octopus Go / Predbat); for pure-solar systems ac_charge is 0 and it's a no-op.

Both bounds matter. The lower clamp (≥ 0) handles export exceeding PV. The upper clamp (≤ pv − grid_export, via flooring pv_to_battery at 0) handles AC→DC conversion loss / counter skew making ac_charge momentarily exceed battery_charge: without it, direct PV could exceed total on-site PV self-consumption, which is nonsensical (a subset can't beat its superset) — Codex review on #313.

Effectively restricted to DC-coupled solar hybrids: on AC-coupled and All-in-One units the PV-generation registers (IR44/45-46) are mislabelled — they carry the inverter's battery-discharge AC output, not PV (#293) — and manifest.py's IR44 identity routing already makes e_pv_generation_today None there, so the pv term's None-propagation below returns None with no separate AC/AIO guard needed. A candidate corrected-AIO formula was evaluated against wire evidence and failed the evidence gate; see manifest.py's module comment. In practice e_battery_charge_today only routes on HYBRID_GEN1 today (see manifest.VALUE_SOURCES / #184), so the field is currently GEN1-effective and returns None on other DC models until that map widens.

Only daily is offered: the lifetime equivalent needs e_ac_charge_total, which single-phase firmware does not expose (three-phase only, IR1378/9).

NOT monotonically increasing intraday — a grid-export burst can dip it — so consumers using state_class TOTAL_INCREASING MUST apply their own monotonic clamp (e.g. HA's monotonic=True path). Returns None if any input is unavailable or the pv term is unavailable for this model (AC/AIO).

e_self_consumption_today property

Self-consumption energy today (kWh): PV generation used on-site (GivTCP parity).

DERIVED: PV generation today − grid export today, clamped at ≥ 0.

Two limitations to be aware of: - Battery-to-grid export is counted in grid_out, so this slightly under-counts true self-consumption when the battery discharges to grid. GivTCP accepts this approximation; we match it here. - NOT monotonically increasing: when battery exports to grid, grid_out rises without pv_generation rising, causing the difference to dip. Consumers that use state_class TOTAL_INCREASING (e.g. HA energy dashboard) MUST apply their own monotonic clamp rather than consuming the raw value directly.

On AC-coupled and All-in-One units this returns None via the same None-propagation as e_consumption_today: manifest.py's IR44 identity routing (#293) already makes e_pv_generation_today None there.

Returns None if any input is unavailable.

e_self_consumption_total property

Lifetime self-consumption energy (kWh): PV generation used on-site (GivTCP parity).

DERIVED: PV generation total − grid export total, clamped at ≥ 0.

Same battery-to-grid under-count and non-monotonicity caveats as e_self_consumption_today: the difference of two running counters is not guaranteed to increase monotonically. Consumers using state_class TOTAL_INCREASING MUST apply a monotonic clamp (e.g. HA's monotonic=True sensor path) rather than consuming the raw value directly.

On AC-coupled and All-in-One units this returns None: manifest.py's IR44 identity routing (#293) already makes e_pv_generation_total None there.

Returns None if any input is unavailable.

enable_standard_self_consumption_logic property

Deprecated alias for enable_inverter_parallel_mode.

f_ac_high_limit_1 property

Deprecated alias for f_ac_high_limit_trip.

f_ac_high_limit_2 property

Deprecated alias for f_ac_high_limit_reconnect.

f_ac_high_limit_3 property

Deprecated alias for f_ac_high_limit_grid.

f_ac_low_limit_1 property

Deprecated alias for f_ac_low_limit_trip.

f_ac_low_limit_2 property

Deprecated alias for f_ac_low_limit_reconnect.

f_ac_low_limit_3 property

Deprecated alias for f_ac_low_limit_grid.

fault_code property

Deprecated alias for inverter_fault_code + inverter_warning_code (recombined).

grid_export_power property

Non-negative grid export power (W); zero when importing or idle (#205).

grid_import_power property

Non-negative grid import power (W); zero when exporting or idle (#205).

inverter_max_power property

Returns the rated inverter power in watts, derived from the device type code.

is_ac_coupled property

True for AC-coupled inverters (no integrated DC battery).

Coarse-family resolution is correct here: AC DTCs resolve to Model.AC / Model.AC_3PH and neither has specific sub-variants. False when the model is unknown (DTC unread).

slot_map property

Register address pairs for the charge/discharge time slots on this model.

t_ac_high_freq_1 property

Deprecated alias for t_ac_high_freq_trip.

t_ac_high_freq_2 property

Deprecated alias for t_ac_high_freq_reconnect.

t_ac_high_voltage_1 property

Deprecated alias for t_ac_high_voltage_trip.

t_ac_high_voltage_2 property

Deprecated alias for t_ac_high_voltage_reconnect.

t_ac_low_freq_1 property

Deprecated alias for t_ac_low_freq_trip.

t_ac_low_freq_2 property

Deprecated alias for t_ac_low_freq_reconnect.

t_ac_low_voltage_1 property

Deprecated alias for t_ac_low_voltage_trip.

t_ac_low_voltage_2 property

Deprecated alias for t_ac_low_voltage_reconnect.

v_ac_high_limit_1 property

Deprecated alias for v_ac_high_limit_trip.

v_ac_high_limit_2 property

Deprecated alias for v_ac_high_limit_reconnect.

v_ac_high_limit_3 property

Deprecated alias for v_ac_high_limit_grid.

v_ac_low_limit_1 property

Deprecated alias for v_ac_low_limit_trip.

v_ac_low_limit_2 property

Deprecated alias for v_ac_low_limit_reconnect.

v_ac_low_limit_3 property

Deprecated alias for v_ac_low_limit_grid.

work_time_total property

Deprecated alias for work_time_total_hours.

e_pv_day()

Computes the total PV energy for the day, or None if either input is unavailable.

Source code in givenergy_modbus/model/inverter.py
1045
1046
1047
1048
1049
def e_pv_day(self) -> float | None:
    """Computes the total PV energy for the day, or None if either input is unavailable."""
    if self.e_pv1_day is None or self.e_pv2_day is None:  # type: ignore[attr-defined]
        return None
    return self.e_pv1_day + self.e_pv2_day  # type: ignore[attr-defined]

from_register_cache(register_cache) classmethod

Construct a SinglePhaseInverter from a RegisterCache.

Source code in givenergy_modbus/model/inverter.py
1034
1035
1036
1037
@classmethod
def from_register_cache(cls, register_cache) -> "SinglePhaseInverter":
    """Construct a SinglePhaseInverter from a RegisterCache."""
    return cls.model_validate(SinglePhaseInverterRegisterGetter(register_cache).build())

p_pv()

Computes the total PV power, or None if either input is unavailable.

Source code in givenergy_modbus/model/inverter.py
1039
1040
1041
1042
1043
def p_pv(self) -> int | None:
    """Computes the total PV power, or None if either input is unavailable."""
    if self.p_pv1 is None or self.p_pv2 is None:  # type: ignore[attr-defined]
        return None
    return self.p_pv1 + self.p_pv2  # type: ignore[attr-defined]

SinglePhaseInverterRegisterGetter

Bases: RegisterGetter

Structured format for all inverter attributes.

Source code in givenergy_modbus/model/inverter.py
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
class SinglePhaseInverterRegisterGetter(RegisterGetter):
    """Structured format for all inverter attributes."""

    REGISTER_LUT = {
        #
        # Holding Registers, block 0-59
        #
        "device_type_code": Def(C.hex, None, HR(0)),
        "model": Def(C.hex, Model, HR(0)),
        "module": Def(C.uint32, (C.hex, 8), HR(1), HR(2)),
        "num_mppt": Def((C.duint8, 0), None, HR(3)),
        "num_phases": Def((C.duint8, 1), None, HR(3)),
        # HR(4-6) unused
        "enable_ammeter": Def(C.bool, None, HR(7)),
        # HR(8-12) was first_battery_serial_number — removed (#191): GivTCP-heritage,
        # unused, unverifiable; on AIO firmware it held the unit serial byte-swapped,
        # not a battery serial. Still redacted in captures via an explicit serial group
        # in client._build_serial_register_groups (recoverable to the real serial).
        "serial_number": Def(C.serial, None, HR(13), HR(14), HR(15), HR(16), HR(17)),
        "first_battery_bms_firmware_version": Def(C.uint16, None, HR(18)),
        "dsp_firmware_version": Def(C.uint16, None, HR(19)),
        "enable_charge_target": Def(C.bool, None, HR(20)),
        "arm_firmware_version": Def(C.uint16, None, HR(21)),
        "firmware_version": Def(C.firmware_version, None, HR(19), HR(21)),
        "usb_device_inserted": Def(C.uint16, UsbDevice, HR(22)),
        "select_arm_chip": Def(C.bool, None, HR(23)),
        "variable_address": Def(C.uint16, None, HR(24)),
        "variable_value": Def(C.uint16, None, HR(25)),
        "grid_port_max_power_output": Def(C.uint16, None, HR(26)),
        "battery_power_mode": Def(C.uint16, BatteryPowerMode, HR(27)),  # eco mode
        "enable_60hz_freq_mode": Def(C.bool, None, HR(28)),
        "battery_calibration_stage": Def(C.uint16, BatteryCalibrationStage, HR(29)),
        "modbus_address": Def(C.uint16, None, HR(30)),
        "charge_slot_2": Def(C.timeslot, None, HR(31), HR(32)),
        "user_code": Def(C.uint16, None, HR(33)),
        "modbus_version": Def(C.centi, (C.fstr, "0.2f"), HR(34)),
        "system_time": Def(C.datetime, None, HR(35), HR(36), HR(37), HR(38), HR(39), HR(40)),
        "enable_drm_rj45_port": Def(C.bool, None, HR(41)),
        "enable_reversed_ct_clamp": Def(C.bool, None, HR(42)),
        "charge_soc": Def((C.duint8, 0), None, HR(43)),
        "discharge_soc": Def((C.duint8, 1), None, HR(43)),
        "discharge_slot_2": Def(C.timeslot, None, HR(44), HR(45)),
        "bms_firmware_version": Def(C.uint16, None, HR(46)),
        "meter_type": Def(C.uint16, MeterType, HR(47)),
        "enable_reversed_115_meter": Def(C.bool, None, HR(48)),
        "enable_reversed_418_meter": Def(C.bool, None, HR(49)),
        "active_power_rate": Def(C.uint16, None, HR(50)),
        "reactive_power_rate": Def(C.uint16, None, HR(51)),
        # Offset-unsigned PF: (raw / 10,000) − 1. See Converter.pf for the encoding
        # rationale and its distinction from the meter's pf_signed registers.
        "power_factor": Def(C.pf, None, HR(52), min=-1.0, max=1.0),
        "enable_inverter_auto_restart": Def((C.duint8, 0), C.bool, HR(53)),
        "enable_inverter": Def((C.duint8, 1), C.bool, HR(53)),
        "battery_type": Def(C.uint16, BatteryType, HR(54)),
        "battery_capacity_ah": Def(C.uint16, None, HR(55)),
        "discharge_slot_1": Def(C.timeslot, None, HR(56), HR(57)),
        "enable_auto_judge_battery_type": Def(C.bool, None, HR(58)),
        "enable_discharge": Def(C.bool, None, HR(59)),
        #
        # Holding Registers, block 60-119
        #
        "v_pv_start": Def(C.uint16, C.deci, HR(60), min=0.0, max=2000.0),
        "start_countdown_timer": Def(C.uint16, None, HR(61)),
        "restart_delay_time": Def(C.uint16, None, HR(62)),
        # AC grid protection — voltage/frequency limits and trip-time settings.
        # Names conceptually aligned with the three-phase equivalents in inverter_threephase.py
        # (using v_ac_/t_ac_ prefixes here vs v_grid_/time_grid_ there).
        # Converters inferred from the 3ph parallel block (HR1018-1042); unverified
        # against a live single-phase capture — mark uncertain with raw uint16 if
        # a future capture disagrees.
        "v_ac_low_limit_trip": Def(C.uint16, C.deci, HR(63), min=0.0, max=500.0),
        "v_ac_high_limit_trip": Def(C.uint16, C.deci, HR(64), min=0.0, max=500.0),
        "f_ac_low_limit_trip": Def(C.uint16, C.centi, HR(65), min=40.0, max=70.0),
        "f_ac_high_limit_trip": Def(C.uint16, C.centi, HR(66), min=40.0, max=70.0),
        "t_ac_low_voltage_trip": Def(C.uint16, C.centi, HR(67)),
        "t_ac_high_voltage_trip": Def(C.uint16, C.centi, HR(68)),
        "t_ac_low_freq_trip": Def(C.uint16, C.centi, HR(69)),
        "t_ac_high_freq_trip": Def(C.uint16, C.centi, HR(70)),
        "v_ac_low_limit_reconnect": Def(C.uint16, C.deci, HR(71), min=0.0, max=500.0),
        "v_ac_high_limit_reconnect": Def(C.uint16, C.deci, HR(72), min=0.0, max=500.0),
        "f_ac_low_limit_reconnect": Def(C.uint16, C.centi, HR(73), min=40.0, max=70.0),
        "f_ac_high_limit_reconnect": Def(C.uint16, C.centi, HR(74), min=40.0, max=70.0),
        "t_ac_low_voltage_reconnect": Def(C.uint16, C.centi, HR(75)),
        "t_ac_high_voltage_reconnect": Def(C.uint16, C.centi, HR(76)),
        "t_ac_low_freq_reconnect": Def(C.uint16, C.centi, HR(77)),
        "t_ac_high_freq_reconnect": Def(C.uint16, C.centi, HR(78)),
        "v_ac_low_limit_grid": Def(C.uint16, C.deci, HR(79), min=0.0, max=500.0),
        "v_ac_high_limit_grid": Def(C.uint16, C.deci, HR(80), min=0.0, max=500.0),
        "f_ac_low_limit_grid": Def(C.uint16, C.centi, HR(81), min=40.0, max=70.0),
        "f_ac_high_limit_grid": Def(C.uint16, C.centi, HR(82), min=40.0, max=70.0),
        "v_ac_10min_protect": Def(C.uint16, C.deci, HR(83), min=0.0, max=500.0),
        # HR(84-93): insulation (ISO), residual-current (GFCI) and DC-injection (DCI)
        # protection thresholds and trip times. GE-named via the installer app; scales
        # unconfirmed on live hardware, so raw read-back (cf. the HR(300-359) block).
        "iso_protection_1": Def(C.uint16, None, HR(84)),
        "iso_protection_2": Def(C.uint16, None, HR(85)),
        "gfci_protection_value_1": Def(C.uint16, None, HR(86)),
        "gfci_protection_time_1": Def(C.uint16, None, HR(87)),
        "gfci_protection_value_2": Def(C.uint16, None, HR(88)),
        "gfci_protection_time_2": Def(C.uint16, None, HR(89)),
        "dci_protection_value_1": Def(C.uint16, None, HR(90)),
        "dci_protection_time_1": Def(C.uint16, None, HR(91)),
        "dci_protection_value_2": Def(C.uint16, None, HR(92)),
        "dci_protection_time_2": Def(C.uint16, None, HR(93)),
        "charge_slot_1": Def(C.timeslot, None, HR(94), HR(95)),
        "enable_charge": Def(C.bool, None, HR(96)),
        "battery_low_voltage_protection_limit": Def(C.uint16, C.centi, HR(97)),
        "battery_high_voltage_protection_limit": Def(C.uint16, C.centi, HR(98)),
        # HR(99-107): string / grid calibration + single-phase config. GE-named via the
        # installer app; scales unconfirmed, so raw read-back. HR(105) keeps its established
        # centi-scaled `battery_voltage_adjust` (the installer name confirms it).
        #
        # HR(101-104) are address-reused across product lines. The installer map (one shared,
        # three-phase-centric name table) calls them GRID_R/S/T_VOLTAGE_ADJUSTMENT (the R/S/T
        # line phases) + GRID_POWER_ADJUSTMENT — the *three-phase* meaning. On single-phase
        # hardware the firmware repurposes them, and both the consumer app v4.0.7 and the
        # library's own write surface (commands.py: GRID_IMPORT_LIMIT / _ENABLED) agree on the
        # 1ph semantics below. ThreePhaseInverterRegisterGetter overrides these four with the
        # R/S/T names. (HR104 type=boolean per the consumer app; HR101-104 read 0 on an
        # unconfigured single-phase capture, and a Gen1 AC hybrid rejected FC06 writes to
        # HR101/102 with an error — so the 1ph names stay documentary, not write-confirmed.)
        "string_1_voltage_adjustment": Def(C.uint16, None, HR(99)),
        "string_2_voltage_adjustment": Def(C.uint16, None, HR(100)),
        "grid_import_limit": Def(C.uint16, None, HR(101)),
        "grid_import_limit_enabled": Def(C.bool, None, HR(102)),
        "enable_lora": Def(C.bool, None, HR(103)),
        "enable_battery_self_heating": Def(C.bool, None, HR(104)),
        "battery_voltage_adjust": Def(C.uint16, C.centi, HR(105)),
        "string_1_power_adjustment": Def(C.uint16, None, HR(106)),
        "string_2_power_adjustment": Def(C.uint16, None, HR(107)),
        "battery_low_force_charge_time": Def(C.uint16, None, HR(108)),
        "enable_bms_read": Def(C.bool, None, HR(109)),
        "battery_soc_reserve": Def(C.uint16, None, HR(110)),
        # HR(111/112): battery charge/discharge rate cap as a C-rate (unit C/100, 0-50 = 0-0.5C) —
        # a battery-side current limit, NOT an inverter-power %. Effective output = min(this C-rate,
        # HR(313/314) inverter %, BMS rating). See commands.set_battery_charge_limit for provenance.
        "battery_charge_limit": Def(C.uint16, None, HR(111)),
        "battery_discharge_limit": Def(C.uint16, None, HR(112)),
        "enable_buzzer": Def(C.bool, None, HR(113)),
        "battery_discharge_min_power_reserve": Def(C.uint16, None, HR(114)),
        # HR(115): left undecoded — the installer app names it IS_LAN, contradicting the
        # earlier 'island_check_continue' guess, so neither reading is decoded until confirmed.
        "charge_target_soc": Def(C.uint16, None, HR(116)),  # requires enable_charge_target
        "charge_soc_stop_2": Def(C.uint16, None, HR(117)),
        "discharge_soc_stop_2": Def(C.uint16, None, HR(118)),
        "charge_soc_stop_1": Def(C.uint16, None, HR(119)),
        #
        # Holding Registers, block 120-179
        #
        "discharge_soc_stop_1": Def(C.uint16, None, HR(120)),
        "enable_local_command_test": Def(C.bool, None, HR(121)),
        "power_factor_function_model": Def(C.uint16, PowerFactorFunctionModel, HR(122)),
        "frequency_load_limit_rate": Def(C.uint16, None, HR(123)),
        "enable_low_voltage_fault_ride_through": Def(C.bool, None, HR(124)),
        "enable_frequency_derating": Def(C.bool, None, HR(125)),
        "enable_above_6kw_system": Def(C.bool, None, HR(126)),
        "start_system_auto_test": Def(C.bool, None, HR(127)),
        "enable_spi": Def(C.bool, None, HR(128)),
        # HR(129-156): power-factor curve, CEI 0-21 and LVFRT grid-compliance settings.
        # GE-named via the installer app; scales unconfirmed (the power_factor values may
        # use GE's EE encoding), so raw read-back. HR(157-161) are absent from the installer
        # map (unnamed) and HR(162) RESET_USER_INFORMATION is a momentary write-trigger, not
        # telemetry — neither is decoded.
        "power_factor_cmd_memory_state": Def(C.uint16, None, HR(129)),
        "power_factor_point_1_load_percent": Def(C.uint16, None, HR(130)),
        "power_factor_point_1_power_factor": Def(C.uint16, None, HR(131)),
        "power_factor_point_2_load_percent": Def(C.uint16, None, HR(132)),
        "power_factor_point_2_power_factor": Def(C.uint16, None, HR(133)),
        "power_factor_point_3_load_percent": Def(C.uint16, None, HR(134)),
        "power_factor_point_3_power_factor": Def(C.uint16, None, HR(135)),
        "power_factor_point_4_load_percent": Def(C.uint16, None, HR(136)),
        "power_factor_point_4_power_factor": Def(C.uint16, None, HR(137)),
        "cei021_v1s_q": Def(C.deci, None, HR(138)),
        "cei021_v2s_q": Def(C.deci, None, HR(139)),
        "cei021_v1l_q": Def(C.deci, None, HR(140)),
        "cei021_v2l_q": Def(C.deci, None, HR(141)),
        "cei021_lock_in_active_power": Def(C.uint16, None, HR(142)),
        "cei021_lock_out_active_power": Def(C.uint16, None, HR(143)),
        "cei021_lock_in_grid_voltage": Def(C.deci, None, HR(144)),
        "cei021_lock_out_grid_voltage": Def(C.deci, None, HR(145)),
        "lvfrt_reactive_rate": Def(C.uint16, None, HR(146)),
        "lvfrt_low_fault_value_1": Def(C.uint16, None, HR(147)),
        "lvfrt_low_fault_time_1": Def(C.uint16, None, HR(148)),
        "lvfrt_low_fault_value_2": Def(C.uint16, None, HR(149)),
        "lvfrt_low_fault_time_2": Def(C.uint16, None, HR(150)),
        "lvfrt_low_fault_value_3": Def(C.uint16, None, HR(151)),
        "lvfrt_low_fault_time_3": Def(C.uint16, None, HR(152)),
        "lvfrt_low_fault_value_4": Def(C.uint16, None, HR(153)),
        "lvfrt_low_fault_time_4": Def(C.uint16, None, HR(154)),
        "lvfrt_high_fault_value_1": Def(C.uint16, None, HR(155)),
        "lvfrt_high_fault_time_1": Def(C.uint16, None, HR(156)),
        "inverter_reboot": Def(C.uint16, None, HR(163)),
        # HR(166): the installer app has no HR(166) entry and exposes no RTC / real-time-
        # control enum, so a rename of enable_rtc → enable_real_time_control can't be
        # substantiated — left as-is pending confirmation rather than trading one ambiguity
        # for another.
        "enable_rtc": Def(C.bool, None, HR(166)),
        "threephase_balance_mode": Def(C.uint16, None, HR(167)),
        "threephase_abc": Def(C.uint16, None, HR(168)),
        "threephase_balance_1": Def(C.uint16, None, HR(169)),
        "threephase_balance_2": Def(C.uint16, None, HR(170)),
        "threephase_balance_3": Def(C.uint16, None, HR(171)),
        # HR(172-174) unused
        "enable_battery_on_pv_or_grid": Def(C.bool, None, HR(175)),
        "debug_inverter": Def(C.uint16, None, HR(176)),
        "enable_ups_mode": Def(C.bool, None, HR(177)),
        "enable_g100_limit_switch": Def(C.bool, None, HR(178)),
        "enable_battery_cable_impedance_alarm": Def(C.bool, None, HR(179)),
        #
        # Holding Registers, block 180-239
        #
        "enable_inverter_parallel_mode": Def(C.bool, None, HR(199)),
        "cmd_bms_flash_update": Def(C.bool, None, HR(200)),
        "inverter_errors": Def(C.uint32, None, HR(223), HR(224)),
        "inverter_fault_messages": Def(C.uint32, _inverter_fault_code, HR(223), HR(224)),
        # 202-239 - Hot Water Diverter?
        #
        # Holding Registers, block 240-299
        # Gen 3 timeslots
        #
        "charge_target_soc_1": Def(C.uint16, None, HR(242)),
        "charge_slot_2_x": Def(C.timeslot, None, HR(243), HR(244)),
        "charge_target_soc_2": Def(C.uint16, None, HR(245)),
        "charge_slot_3": Def(C.timeslot, None, HR(246), HR(247)),
        "charge_target_soc_3": Def(C.uint16, None, HR(248)),
        "charge_slot_4": Def(C.timeslot, None, HR(249), HR(250)),
        "charge_target_soc_4": Def(C.uint16, None, HR(251)),
        "charge_slot_5": Def(C.timeslot, None, HR(252), HR(253)),
        "charge_target_soc_5": Def(C.uint16, None, HR(254)),
        "charge_slot_6": Def(C.timeslot, None, HR(255), HR(256)),
        "charge_target_soc_6": Def(C.uint16, None, HR(257)),
        "charge_slot_7": Def(C.timeslot, None, HR(258), HR(259)),
        "charge_target_soc_7": Def(C.uint16, None, HR(260)),
        "charge_slot_8": Def(C.timeslot, None, HR(261), HR(262)),
        "charge_target_soc_8": Def(C.uint16, None, HR(263)),
        "charge_slot_9": Def(C.timeslot, None, HR(264), HR(265)),
        "charge_target_soc_9": Def(C.uint16, None, HR(266)),
        "charge_slot_10": Def(C.timeslot, None, HR(267), HR(268)),
        "charge_target_soc_10": Def(C.uint16, None, HR(269)),
        "discharge_target_soc_1": Def(C.uint16, None, HR(272)),
        "discharge_target_soc_2": Def(C.uint16, None, HR(275)),
        "discharge_slot_3": Def(C.timeslot, None, HR(276), HR(277)),
        "discharge_target_soc_3": Def(C.uint16, None, HR(278)),
        "discharge_slot_4": Def(C.timeslot, None, HR(279), HR(280)),
        "discharge_target_soc_4": Def(C.uint16, None, HR(281)),
        "discharge_slot_5": Def(C.timeslot, None, HR(282), HR(283)),
        "discharge_target_soc_5": Def(C.uint16, None, HR(284)),
        "discharge_slot_6": Def(C.timeslot, None, HR(285), HR(286)),
        "discharge_target_soc_6": Def(C.uint16, None, HR(287)),
        "discharge_slot_7": Def(C.timeslot, None, HR(288), HR(289)),
        "discharge_target_soc_7": Def(C.uint16, None, HR(290)),
        "discharge_slot_8": Def(C.timeslot, None, HR(291), HR(292)),
        "discharge_target_soc_8": Def(C.uint16, None, HR(293)),
        "discharge_slot_9": Def(C.timeslot, None, HR(294), HR(295)),
        "discharge_target_soc_9": Def(C.uint16, None, HR(296)),
        "discharge_slot_10": Def(C.timeslot, None, HR(297), HR(298)),
        "discharge_target_soc_10": Def(C.uint16, None, HR(299)),
        #
        # Holding Registers, block 554-573
        # Smart Load scheduling — 10 time-window slots (HR554-573).
        # app: "Smart Load Start/End Time 1..10"
        #
        "smart_load_slot_1": Def(C.timeslot, None, HR(554), HR(555)),
        "smart_load_slot_2": Def(C.timeslot, None, HR(556), HR(557)),
        "smart_load_slot_3": Def(C.timeslot, None, HR(558), HR(559)),
        "smart_load_slot_4": Def(C.timeslot, None, HR(560), HR(561)),
        "smart_load_slot_5": Def(C.timeslot, None, HR(562), HR(563)),
        "smart_load_slot_6": Def(C.timeslot, None, HR(564), HR(565)),
        "smart_load_slot_7": Def(C.timeslot, None, HR(566), HR(567)),
        "smart_load_slot_8": Def(C.timeslot, None, HR(568), HR(569)),
        "smart_load_slot_9": Def(C.timeslot, None, HR(570), HR(571)),
        "smart_load_slot_10": Def(C.timeslot, None, HR(572), HR(573)),
        #
        # Holding Registers, block 300-359 — AC-output config.
        # Polled only when has_ac_config_block is set (AC-coupled and All-in-One
        # models); DC-coupled/hybrid models time out on it and never poll it (#162),
        # so every field below stays None there — same as battery_*_limit_ac (HR313/314).
        # See client.py load_config().
        #
        # Newly decoded installer-config registers (HR300-351) from the GivEnergy app v4.0.7,
        # polled on AC/AIO via the HR(300,60) block. Enable/disable toggles decode via C.bool,
        # matching the 1/0 installer setters in commands.py; thresholds, modes, slopes and
        # limits decode as raw uint16 + None — scale/semantics are unconfirmed on live
        # hardware, but raw read-back still lets a consumer verify an installer write. HR301
        # selects the plant's primary/secondary role; named plant_role to avoid the app's
        # non-inclusive label. It is decode-only — not in INSTALLER_WRITE_REGISTERS.
        "enable_plant_mode": Def(C.bool, None, HR(300)),
        "plant_role": Def(C.uint16, None, HR(301)),
        "plant_meters": Def(C.uint16, None, HR(302)),
        "overfrequency_load_drop_recovery_delay": Def(C.uint16, None, HR(303)),
        "mppt_operating_mode": Def(C.uint16, None, HR(305)),
        "connection_loading_slope": Def(C.uint16, None, HR(306)),
        "eps_nominal_voltage": Def(C.deci, None, HR(307)),
        # HR(308-310): battery topology — rated power (W), rated current (A), max charge
        # percentage. From the GivEnergy app v4.0.7; scale unconfirmed on live hardware.
        "battery_nominal_power": Def(C.uint16, None, HR(308)),
        "battery_nominal_current": Def(C.uint16, None, HR(309)),
        "battery_max_charge_pct": Def(C.uint16, None, HR(310)),
        # HR(311): export priority — confirmed writable on Model.AC via portal observations
        # (hass#52): values 0/1/2 = "Load First / Battery First / Grid First" (per ExportPriority, #303).
        "export_priority": Def(C.uint16, ExportPriority, HR(311)),
        # HR(312): underfrequency add-load delay (raw uint16, scale unconfirmed).
        "underfrequency_add_load_delay": Def(C.uint16, None, HR(312)),
        "battery_charge_limit_ac": Def(C.uint16, None, HR(313)),
        "battery_discharge_limit_ac": Def(C.uint16, None, HR(314)),
        # HR(315-316): EN50549 zero-current static voltage limits (raw uint16).
        "en50549_zero_current_lower_voltage_limit": Def(C.deci, None, HR(315)),
        "en50549_zero_current_upper_voltage_limit": Def(C.deci, None, HR(316)),
        # HR(317): EPS enable — confirmed writable on Model.AC via portal observations
        # (hass#52): toggled 0/1 while the portal's "EPS" switch was flipped off/on.
        "enable_eps": Def(C.bool, None, HR(317)),
        "battery_pause_mode": Def(C.uint16, None, HR(318)),
        "battery_pause_slot_1": Def(C.timeslot, None, HR(319), HR(320)),
        # HR(321-332): frequency derating, tariff-pricing battery logic, BMS OCV calibration,
        # gateway power-off, force-off-grid, micro-grid.
        "overfrequency_derating_start_point": Def(C.centi, None, HR(321)),
        "enable_tariff_pricing_battery_logic": Def(C.bool, None, HR(322)),
        "import_price_battery_discharge_threshold": Def(C.uint16, None, HR(323)),
        "import_price_battery_charge_threshold": Def(C.uint16, None, HR(324)),
        "export_price_battery_discharge_threshold": Def(C.uint16, None, HR(325)),
        "underfrequency_derating_start_point": Def(C.centi, None, HR(326)),
        "underfrequency_loading_slope": Def(C.uint16, None, HR(327)),
        "overfrequency_derating_stop_point": Def(C.centi, None, HR(328)),
        "enable_bms_ocv_calibration": Def(C.bool, None, HR(329)),
        "gateway_power_off_setting": Def(C.uint16, None, HR(330)),
        "force_off_grid": Def(C.bool, None, HR(331)),
        "enable_micro_grid": Def(C.bool, None, HR(332)),
        # HR(333-336): EV charger config (enable, import limit, reconnection wait, SOC limit).
        "enable_ev_charger": Def(C.bool, None, HR(333)),
        "ev_charger_import_limit": Def(C.uint16, None, HR(334)),
        "ev_charger_reconnection_wait_time": Def(C.uint16, None, HR(335)),
        "ev_charger_soc_limit": Def(C.uint16, None, HR(336)),
        # HR(337-342): fan, gateway, BMS comms, N-PE relay, AFCI.
        "enable_fan": Def(C.bool, None, HR(337)),
        "fan_speed": Def(C.uint16, None, HR(338)),
        "enable_gateway": Def(C.bool, None, HR(339)),
        "bms_communication_mode": Def(C.uint16, None, HR(340)),
        "n_pe_relay_toggle": Def(C.uint16, None, HR(341)),
        "afci_setting": Def(C.uint16, None, HR(342)),
        # HR(343-346): generator config (enable, start/stop SOC, charge power).
        "enable_generator": Def(C.bool, None, HR(343)),
        "generator_start_soc": Def(C.uint16, None, HR(344)),
        "generator_stop_soc": Def(C.uint16, None, HR(345)),
        "generator_charge_power": Def(C.uint16, None, HR(346)),
        # HR(347-351): display, lead-acid calibration limits, inverter operating mode.
        "disable_leds": Def(C.bool, None, HR(347)),
        "lcd_screen_idle_timeout": Def(C.uint16, None, HR(348)),
        "lead_acid_battery_calibration_upper_limit": Def(C.centi, None, HR(349)),
        "lead_acid_battery_calibration_lower_limit": Def(C.centi, None, HR(350)),
        "inverter_operating_mode": Def(C.uint16, None, HR(351)),
        #
        # Holding Registers, block 499-510
        # HV cabinet topology — from the GivEnergy app v4.0.7; poll gated off for
        # every model pending a confirming hardware capture (see _HV_CABINET_MODELS in
        # plant.py). Values are raw uint16 counts/ratings; exact scaling unconfirmed.
        #
        "hv_cabinet_count": Def(C.uint16, None, HR(499)),
        "hv_racks_per_cabinet": Def(C.uint16, None, HR(500)),
        "hv_batteries_per_rack": Def(C.uint16, None, HR(501)),
        "hv_cells_per_battery": Def(C.uint16, None, HR(502)),
        "hv_total_cells": Def(C.uint16, None, HR(503)),
        "hv_temp_sensors_per_battery": Def(C.uint16, None, HR(504)),
        "hv_total_temp_sensors": Def(C.uint16, None, HR(505)),
        "hv_max_pcs_power": Def(C.uint16, None, HR(506)),
        "hv_max_charge_voltage": Def(C.deci, None, HR(507)),
        "hv_min_discharge_voltage": Def(C.uint16, None, HR(508)),
        "hv_max_charge_current": Def(C.uint16, None, HR(509)),
        "hv_parallel_count": Def(C.uint16, None, HR(510)),
        #
        # Holding Registers, block 20000-20051
        # Peak-shaving / valley-filling — from the GivEnergy app v4.0.7; poll gated
        # off for every model pending a confirming hardware capture (see _PEAK_SHAVING_MODELS
        # in plant.py). Sparse: defined registers at 20000-20003, 20020-20021, 20050-20051.
        #
        "peak_shaving_export_limit_enabled": Def(C.bool, None, HR(20000)),
        "peak_shaving_export_limit": Def(C.uint16, None, HR(20001)),
        "peak_shaving_enabled": Def(C.bool, None, HR(20002)),
        "peak_shaving_threshold": Def(C.uint16, None, HR(20003)),
        "peak_shaving_import_limit_enabled": Def(C.bool, None, HR(20020)),
        "peak_shaving_import_limit": Def(C.uint16, None, HR(20021)),
        "peak_shaving_power": Def(C.uint16, None, HR(20050)),
        "valley_filling_power": Def(C.uint16, None, HR(20051)),
        #
        # Holding Registers, block 4080-4139
        #
        "pv_power_setting": Def(C.uint32, None, HR(4107), HR(4108)),
        # DEAD: never polled. No read path in this repo or GivTCP requests HR>=4000
        # (our max poll base is HR(240); GivTCP's add_regs detect-probe candidates top
        # out at HR(300)). Defined for naming symmetry with the IR alt sources and as
        # scaffold for a future #48 "does this block respond on real hardware?" probe;
        # no model's manifest.VALUE_SOURCES entry routes here. The C.deci scaling diverges
        # from GivTCP's raw (None) defs — moot while unpolled, reconcile under #48.
        "e_battery_discharge_total_alt2": Def(C.uint32, C.deci, HR(4109), HR(4110)),
        "e_battery_charge_total_alt2": Def(C.uint32, C.deci, HR(4111), HR(4112)),
        "e_battery_discharge_today_alt3": Def(C.uint16, C.deci, HR(4113)),
        "e_battery_charge_today_alt3": Def(C.uint16, C.deci, HR(4114)),
        #
        # Holding Registers, block 4140-4199
        #
        # 0.1 kWh per the v4.1.6 doc, matching the sibling cumulative totals (e_pv_total,
        # e_battery_throughput) which already use C.deci; raw uint32 over-reported 10×. Not
        # currently in the library poll path (max base HR240), so this corrects any direct read
        # (#182). Doc-driven — no wire capture confirms the magnitude, as for the adjacent _alt
        # energy block.
        "e_inverter_export_total": Def(C.uint32, C.deci, HR(4141), HR(4142)),
        #
        # Input Registers, block 0-59
        #
        # Power-register physical nodes (single-phase Hybrid, empirically established
        # against SA2114G047 — see docs/usage.md "Power register measurement points"):
        # the instantaneous "grid" registers do NOT all measure the same point.
        #
        #   PV ─DC─┐                       ┌─► house load            IR(42) p_load_demand
        #          ├─[INVERTER]─AC─busbar──┤
        #   Bat ─DC┘   terminal            └─► grid CT ─► meter      IR(30) p_grid_out
        #          IR(24)/IR(43)/IR(58)         boundary
        #
        # IR(24) p_grid_out_ph1, IR(43) p_grid_apparent, IR(58) i_grid_port → INVERTER
        #   AC terminal (real W / apparent VA / current at the busbar).
        # IR(30) p_grid_out → EXTERNAL grid CT net flow (the meter boundary).
        # IR(42) p_load_demand → house load, independently sensed (not IR24−IR30).
        # The shared "grid" prefix hides that IR(24)≠IR(30) are different nodes.
        "status": Def(C.uint16, Status, IR(0)),
        "v_pv1": Def(C.deci, None, IR(1), min=0.0, max=2000.0),
        "v_pv2": Def(C.deci, None, IR(2), min=0.0, max=2000.0),
        "v_p_bus": Def(C.deci, None, IR(3)),
        "v_n_bus": Def(C.deci, None, IR(4)),
        "v_ac1": Def(C.deci, None, IR(5), min=0.0, max=500.0),
        "e_battery_throughput": Def(C.uint32, C.deci, IR(6), IR(7)),
        "i_pv1": Def(C.deci, None, IR(8), min=0.0, max=500.0),
        "i_pv2": Def(C.deci, None, IR(9), min=0.0, max=500.0),
        "i_ac1": Def(C.deci, None, IR(10), min=0.0, max=500.0),
        "e_pv_total": Def(C.uint32, C.deci, IR(11), IR(12)),
        "f_ac1": Def(C.centi, None, IR(13), min=40.0, max=70.0),
        "charge_status": Def(C.uint16, None, IR(14), deprecated="use charge_status_label"),
        "charge_status_label": Def(C.uint16, _charge_status_from, IR(14)),
        "v_highbrigh_bus": Def(C.deci, None, IR(15)),
        "pf_inverter_output_now": Def(C.pf, None, IR(16), min=-1.0, max=1.0),  # offset-unsigned, see power_factor
        "e_pv1_day": Def(C.deci, None, IR(17)),
        "p_pv1": Def(C.uint16, None, IR(18), max=50000),
        "e_pv2_day": Def(C.deci, None, IR(19)),
        "p_pv2": Def(C.uint16, None, IR(20), max=50000),
        "e_grid_out_total": Def(C.uint32, C.deci, IR(21), IR(22)),
        "e_solar_diverter": Def(C.deci, None, IR(23)),
        # Inverter AC grid-terminal real power (flow onto the busbar), +ve = delivering.
        # NOT the external grid CT despite the "grid_out" name — see the node note above.
        "p_grid_out_ph1": Def(C.int16, None, IR(24)),
        "e_grid_out_day": Def(C.deci, None, IR(25)),
        "e_grid_in_day": Def(C.deci, None, IR(26)),
        "e_inverter_in_total": Def(C.uint32, C.deci, IR(27), IR(28)),
        "e_discharge_year": Def(C.deci, None, IR(29)),
        # External grid-CT net flow at the meter boundary, +ve = export / −ve = import.
        # A DIFFERENT physical node from IR(24): IR(30) is the grid clamp, IR(24) is the
        # inverter terminal. (GivTCP's `grid_power` reads this same register.)
        "p_grid_out": Def(C.int16, None, IR(30)),
        "p_backup": Def(C.uint16, None, IR(31), max=50000),  # EPS
        "e_grid_in_total": Def(C.uint32, C.deci, IR(32), IR(33)),
        # IR(34) unknown, skip
        # IR(35) is AC-charge-today, NOT house-load/consumption. The GivTCP-era
        # "e_load_day" name was a mislabel (#174): sentinel cross-correlation via the
        # GE app's Energy-today screen confirmed IR(35) backs "AC charge today". The
        # app's "Consumption today" is computed, not a register — see e_consumption_today
        # on SinglePhaseInverter. Aligned to the existing three-phase e_ac_charge_today
        # name so the three-phase native (IR1376/7) overrides this inherited entry.
        "e_ac_charge_today": Def(C.deci, None, IR(35)),
        "e_battery_charge_today_alt1": Def(C.deci, None, IR(36)),
        "e_battery_discharge_today_alt1": Def(C.deci, None, IR(37)),
        "countdown": Def(C.uint16, None, IR(38)),
        # IR(39)/IR(40) are two distinct 16-bit words per the GivEnergy installer app
        # v1.154.3 (IR39 INVERTER_FAULT_CODE, IR40 INVERTER_WARNING_CODE), not one uint32.
        # The old combined "fault_code" (uint32 over both) is kept as a deprecated alias on
        # SinglePhaseInverter that recombines them. Left as raw hex words — IR(39)'s bitfield
        # enum isn't confirmed, and the separate HR(223/224) inverter_fault_messages source
        # is unreconciled (see #330), so no message decode here.
        "inverter_fault_code": Def(C.uint16, (C.hex, 4), IR(39)),
        "inverter_warning_code": Def(C.uint16, (C.hex, 4), IR(40)),
        # Temperatures are signed two's-complement deci (v4.1.6 register map 4.1.2:
        # Inverter Temperature / CHG_Temper / Bat_Temper = "signed" 0.1c) — the negative
        # floor is a real bound, so a genuine sub-zero ambient reading decodes correctly.
        "t_inverter_heatsink": Def(C.int16, C.deci, IR(41), min=-40.0, max=100.0),
        # House load / consumption at the busbar, independently sensed — empirically NOT
        # a derived IR(24)−IR(30) identity (residual non-zero in 68% of samples, though
        # small and centred on zero). That residual is NOT the EPS branch: across 259
        # EPS-active samples (HYBRID 0x31 + AIO 0x11) it tracks 0, not p_backup (IR31), so
        # IR42 already INCLUDES EPS ("of which", not additional) — consumers must not add
        # IR(31) on top. See tests/debug/eps_in_load_demand.py.
        "p_load_demand": Def(C.uint16, None, IR(42), max=50000),
        # Inverter AC grid-terminal apparent power (VA); pairs with IR(24), not IR(30)
        # (IR43/IR24 → plausible PF ~0.9; IR43/IR30 → impossible). Same node as IR(24).
        "p_grid_apparent": Def(C.uint16, None, IR(43), max=50000),
        # IR(44) is PV-generation-today and IR(45/46) is PV-generation-total on most
        # models (sentinel cross-correlation, #174) — but on AC/ALL_IN_ONE they carry
        # the inverter's battery-discharge AC output instead (#293): see
        # _apply_field_identity below, which moves the decoded value across to
        # e_inverter_out_today/_total on exactly those two models. Renamed from
        # "e_inverter_out_day" / "e_inverter_out_total"; "day" is kept as a deprecated
        # @property alias for a release ("total" is now a real field, live on AC/AIO).
        # NB: this rename is single-phase only. ThreePhaseInverter has its OWN native
        # e_inverter_out_total (IR1362/3) — a genuine, distinct register from its PV
        # total (e_pv_total, IR1374/5) — so the 3ph field is left untouched.
        "e_pv_generation_today": Def(C.deci, None, IR(44)),
        "e_pv_generation_total": Def(C.uint32, C.deci, IR(45), IR(46)),
        # Hours since first power-on. Wire data on HYBRID_GEN1 ticks once per
        # wall-clock hour and persists across reboots; cap at ~100 years to
        # reject obviously-garbage uint32 values. The `_hours` suffix carries
        # the unit at the call site (see #84); `work_time_total` is preserved
        # as a deprecated alias on the inverter classes for a release.
        "work_time_total_hours": Def(C.uint32, None, IR(47), IR(48), max=876_000),
        "system_mode": Def(C.uint16, None, IR(49)),
        # max 600 V (not 100): IR(50) is inverter-level battery voltage, and on an AIO it
        # carries the HV integrated stack (~300-500 V), not just an LV pack (~50 V). The old
        # LV-calibrated 100 V ceiling clamped a real HV reading to None (permanently-Unknown
        # entity). 600 V admits both while still suppressing raw garbage (65535 -> 655.35).
        "v_battery": Def(C.centi, None, IR(50), min=0.0, max=600.0),
        "i_battery": Def(C.int16, C.centi, IR(51), min=-300.0, max=300.0),
        "p_battery": Def(C.int16, None, IR(52)),
        "v_ac1_output": Def(C.deci, None, IR(53), min=0.0, max=500.0),  # might be v_eps_backup?
        "f_ac1_output": Def(C.centi, None, IR(54), min=40.0, max=70.0),  # might be f_eps_backup?
        "t_charger": Def(C.int16, C.deci, IR(55), min=-40.0, max=100.0),
        "t_battery": Def(C.int16, C.deci, IR(56), min=-40.0, max=100.0),
        "charger_warning_code": Def(C.uint16, None, IR(57)),
        "charger_warning_messages": Def(C.uint16, _charger_warning_code, IR(57)),
        # Inverter AC grid-terminal current; pairs with IR(24)/IR(43) (I×V ≈ IR43 VA),
        # the inverter terminal — not the external CT. Same node as IR(24).
        "i_grid_port": Def(C.centi, None, IR(58)),
        "battery_soc": Def(C.uint16, None, IR(59), min=0, max=100),
        #
        # Input Registers, block 180-239
        #
        "e_battery_discharge_total_alt1": Def(C.deci, None, IR(180)),
        "e_battery_charge_total_alt1": Def(C.deci, None, IR(181)),
        "e_battery_discharge_today_alt2": Def(C.deci, None, IR(182)),
        "e_battery_charge_today_alt2": Def(C.deci, None, IR(183)),
        #
        # Input Registers, block 240-300
        # Gen3
        #
        "p_combined_generation": Def(C.uint32, None, IR(247), IR(248), max=100000),
    }

Status

Bases: int, Enum

Inverter status.

Source code in givenergy_modbus/model/inverter.py
362
363
364
365
366
367
368
369
class Status(int, Enum):
    """Inverter status."""

    WAITING = 0
    NORMAL = 1
    WARNING = 2
    FAULT = 3
    FLASHING_FIRMWARE_UPDATE = 4

UsbDevice

Bases: int, Enum

USB devices that can be inserted into inverters.

Source code in givenergy_modbus/model/inverter.py
308
309
310
311
312
313
class UsbDevice(int, Enum):
    """USB devices that can be inserted into inverters."""

    NONE = 0
    WIFI = 1
    DISK = 2

WorkMode

Bases: IntEnum

Inverter work mode.

Source code in givenergy_modbus/model/inverter.py
372
373
374
375
376
377
378
379
380
381
382
383
class WorkMode(IntEnum):
    """Inverter work mode."""

    INITIALISING = 0
    OFF_GRID = 1
    ON_GRID = 2
    FAULT = 3
    UPDATE = 4

    @classmethod
    def _missing_(cls, value):
        return cls.INITIALISING

__getattr__(name)

PEP 562 module attribute deprecation shim.

Serves two deprecated names — the pre-existing Inverter rename alias, and AC_COUPLED_MODELS (moved to manifest.CAPABILITIES["is_ac_coupled"], #293 Slice B). Both are kept accessible here for backward compatibility until the 3.0 deprecation horizon. Deliberately a single __getattr__: Python only honours the last one defined at module scope, so a second definition would silently shadow this one rather than raising or merging.

Source code in givenergy_modbus/model/inverter.py
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
def __getattr__(name: str):
    """PEP 562 module attribute deprecation shim.

    Serves two deprecated names — the pre-existing `Inverter` rename alias, and
    AC_COUPLED_MODELS (moved to manifest.CAPABILITIES["is_ac_coupled"], #293 Slice B).
    Both are kept accessible here for backward compatibility until the 3.0
    deprecation horizon. Deliberately a single `__getattr__`: Python only honours the
    last one defined at module scope, so a second definition would silently shadow
    this one rather than raising or merging.
    """
    if name == "Inverter":
        warnings.warn(
            "Inverter has been renamed to SinglePhaseInverter and the alias will be removed in a future release. "
            "Use select_inverter() to obtain the correct model for a given device.",
            DeprecationWarning,
            stacklevel=2,
        )
        return SinglePhaseInverter
    if name == "AC_COUPLED_MODELS":
        warnings.warn(
            "givenergy_modbus.model.inverter.AC_COUPLED_MODELS is deprecated; "
            "use givenergy_modbus.model.manifest.has_capability('is_ac_coupled', model)",
            DeprecationWarning,
            stacklevel=2,
        )
        from givenergy_modbus.model.manifest import CAPABILITIES

        return CAPABILITIES["is_ac_coupled"]
    raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

inverter_address_for(model)

Return the modbus device address the inverter's registers are served at.

0x11 is the canonical inverter address for all models. AC and HYBRID_GEN1 units additionally expose their registers at 0x31 — a facade over the same register file (value-equality verified across 114 shared HR registers on a live HYBRID_GEN1, and byte-identical HR banks on two live AC units; #189) — but 0x11 is where the official app reads and writes, and where detect() always reads identity. EMS and All-in-One controllers likewise serve all their data — including the IR/HR(2040) rollup — at 0x11.

Capabilities persisted before the 0x31 retirement may still carry an explicit inverter_address of 0x31; that keeps working (the hardware facade still answers there) and self-heals to 0x11 on the next detect().

Source code in givenergy_modbus/model/inverter.py
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
def inverter_address_for(model: Model) -> int:
    """Return the modbus device address the inverter's registers are served at.

    `0x11` is the canonical inverter address for all models. AC and HYBRID_GEN1
    units additionally expose their registers at `0x31` — a facade over the same
    register file (value-equality verified across 114 shared HR registers on a
    live HYBRID_GEN1, and byte-identical HR banks on two live AC units; #189) —
    but `0x11` is where the official app reads and writes, and where ``detect()``
    always reads identity. EMS and All-in-One controllers likewise serve all
    their data — including the IR/HR(2040) rollup — at `0x11`.

    Capabilities persisted before the `0x31` retirement may still carry an
    explicit ``inverter_address`` of `0x31`; that keeps working (the hardware
    facade still answers there) and self-heals to `0x11` on the next
    ``detect()``.
    """
    return 0x11

resolve_model(raw_dtc, arm_fw)

Return the most specific Model for a given device type code and ARM firmware version.

raw_dtc is the raw integer value of HR(0) (e.g. 0x2001). arm_fw is the raw ARM firmware version integer from HR(21).

Use this in preference to plain Model(dtc) when you have both values available. Model(dtc) continues to work and returns the coarse family for backward compat.

Source code in givenergy_modbus/model/inverter.py
273
274
275
276
277
278
279
280
281
282
283
284
285
286
def resolve_model(raw_dtc: int, arm_fw: int) -> Model:
    """Return the most specific Model for a given device type code and ARM firmware version.

    `raw_dtc` is the raw integer value of HR(0) (e.g. 0x2001).
    `arm_fw` is the raw ARM firmware version integer from HR(21).

    Use this in preference to plain `Model(dtc)` when you have both values available.
    `Model(dtc)` continues to work and returns the coarse family for backward compat.
    """
    dtc = f"{raw_dtc:04x}"
    prefix = dtc[:2]
    if prefix == "20":
        return _HYBRID_FW_CENTURY_TO_GEN.get(arm_fw // 100, Model.HYBRID_GEN1)
    return _DTC_PREFIX_TO_MODEL.get(prefix, Model(dtc))

Battery

Bases: _BatteryBase, RegisterMetadataMixin

GivEnergy battery data model.

Source code in givenergy_modbus/model/battery.py
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
class Battery(_BatteryBase, RegisterMetadataMixin):  # type: ignore[misc,valid-type]
    """GivEnergy battery data model."""

    REGISTER_GETTER: ClassVar[type[RegisterGetter]] = BatteryRegisterGetter

    @classmethod
    def from_register_cache(cls, register_cache) -> "Battery":
        """Construct a Battery from a RegisterCache."""
        return cls.model_validate(BatteryRegisterGetter(register_cache).build())

    def is_valid(self) -> bool:
        """Try to detect if a battery exists based on its attributes."""
        return is_valid_serial(self.serial_number)  # type: ignore[attr-defined]

    @computed_field  # type: ignore[prop-decorator]
    @property
    def remaining_energy_nominal_wh(self) -> int | None:
        """Remaining stored energy (Wh), nominal-voltage basis: cap_remaining × num_cells × 3.2 V.

        Nominal voltage is stable across load/SOC (the basis nameplate capacity is quoted on),
        so this is the figure to sum for a slowly-varying "remaining energy" reading (#374). If
        the pack doesn't report num_cells, fall back to the measured pack voltage so it still
        contributes to a plant-level sum rather than silently dropping — the exact failure mode
        #318 is about. None if cap_remaining is None or no voltage basis is available.
        """
        cap = self.cap_remaining  # type: ignore[attr-defined]
        if cap is None:
            return None
        nominal_v = self.num_cells * _LIFEPO4_NOMINAL_CELL_V if self.num_cells else self.v_out  # type: ignore[attr-defined]
        if nominal_v is None:
            return None
        return round(cap * nominal_v)

    @computed_field  # type: ignore[prop-decorator]
    @property
    def remaining_energy_measured_wh(self) -> int | None:
        """Remaining stored energy (Wh), measured-voltage basis: cap_remaining × v_out.

        Instantaneous; fluctuates ~2-3% with SOC and sags under load. None if cap_remaining or
        v_out is None.
        """
        cap = self.cap_remaining  # type: ignore[attr-defined]
        v_out = self.v_out  # type: ignore[attr-defined]
        if cap is None or v_out is None:
            return None
        return round(cap * v_out)

remaining_energy_measured_wh property

Remaining stored energy (Wh), measured-voltage basis: cap_remaining × v_out.

Instantaneous; fluctuates ~2-3% with SOC and sags under load. None if cap_remaining or v_out is None.

remaining_energy_nominal_wh property

Remaining stored energy (Wh), nominal-voltage basis: cap_remaining × num_cells × 3.2 V.

Nominal voltage is stable across load/SOC (the basis nameplate capacity is quoted on), so this is the figure to sum for a slowly-varying "remaining energy" reading (#374). If the pack doesn't report num_cells, fall back to the measured pack voltage so it still contributes to a plant-level sum rather than silently dropping — the exact failure mode

318 is about. None if cap_remaining is None or no voltage basis is available.

from_register_cache(register_cache) classmethod

Construct a Battery from a RegisterCache.

Source code in givenergy_modbus/model/battery.py
116
117
118
119
@classmethod
def from_register_cache(cls, register_cache) -> "Battery":
    """Construct a Battery from a RegisterCache."""
    return cls.model_validate(BatteryRegisterGetter(register_cache).build())

is_valid()

Try to detect if a battery exists based on its attributes.

Source code in givenergy_modbus/model/battery.py
121
122
123
def is_valid(self) -> bool:
    """Try to detect if a battery exists based on its attributes."""
    return is_valid_serial(self.serial_number)  # type: ignore[attr-defined]

BatteryMaintenance

Bases: IntEnum

Battery maintenance mode.

Source code in givenergy_modbus/model/battery.py
199
200
201
202
203
204
205
206
207
208
209
class BatteryMaintenance(IntEnum):
    """Battery maintenance mode."""

    OFF = 0
    DISCHARGE = 1
    CHARGE = 2
    STANDBY = 3

    @classmethod
    def _missing_(cls, value):
        return cls.OFF

BatteryPauseMode

Bases: IntEnum

Battery pause mode.

Source code in givenergy_modbus/model/battery.py
186
187
188
189
190
191
192
193
194
195
196
class BatteryPauseMode(IntEnum):
    """Battery pause mode."""

    DISABLED = 0
    PAUSE_CHARGE = 1
    PAUSE_DISCHARGE = 2
    PAUSE_BOTH = 3

    @classmethod
    def _missing_(cls, value):
        return cls.DISABLED

BatteryRegisterGetter

Bases: RegisterGetter

Structured format for all battery attributes.

Source code in givenergy_modbus/model/battery.py
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
class BatteryRegisterGetter(RegisterGetter):
    """Structured format for all battery attributes."""

    REGISTER_LUT = {
        # Input Registers, block 60-119
        "v_cell_01": Def(DT.milli, None, IR(60), min=1.0, max=5.0),
        "v_cell_02": Def(DT.milli, None, IR(61), min=1.0, max=5.0),
        "v_cell_03": Def(DT.milli, None, IR(62), min=1.0, max=5.0),
        "v_cell_04": Def(DT.milli, None, IR(63), min=1.0, max=5.0),
        "v_cell_05": Def(DT.milli, None, IR(64), min=1.0, max=5.0),
        "v_cell_06": Def(DT.milli, None, IR(65), min=1.0, max=5.0),
        "v_cell_07": Def(DT.milli, None, IR(66), min=1.0, max=5.0),
        "v_cell_08": Def(DT.milli, None, IR(67), min=1.0, max=5.0),
        "v_cell_09": Def(DT.milli, None, IR(68), min=1.0, max=5.0),
        "v_cell_10": Def(DT.milli, None, IR(69), min=1.0, max=5.0),
        "v_cell_11": Def(DT.milli, None, IR(70), min=1.0, max=5.0),
        "v_cell_12": Def(DT.milli, None, IR(71), min=1.0, max=5.0),
        "v_cell_13": Def(DT.milli, None, IR(72), min=1.0, max=5.0),
        "v_cell_14": Def(DT.milli, None, IR(73), min=1.0, max=5.0),
        "v_cell_15": Def(DT.milli, None, IR(74), min=1.0, max=5.0),
        "v_cell_16": Def(DT.milli, None, IR(75), min=1.0, max=5.0),
        # Temperatures are SIGNED two's-complement deci (v4.1.6 register map 4.4.1.2:
        # Cell*_Temp / MOS_Temp / T_max / T_min = "signed" 0.1c) — so a genuine sub-zero
        # cell decodes correctly instead of as a huge positive. The BMS stores temperatures
        # with a `+2730` bias and subtracts 2730 on TX, so an empty slot (internal `0`)
        # emits `0xF556 = -2730 = -273.0 °C`; the `min=-60.0` bound rejects that sentinel.
        # See open-giv/bms-analysis docs/03 ("Absent device pattern") for the firmware path.
        # If you tighten these bounds, keep the lower bound above `-273.0` or add explicit
        # sentinel rejection — otherwise empty-slot frames will start reaching consumers.
        "t_cells_01_04": Def(DT.int16, DT.deci, IR(76), min=-60.0, max=150.0),
        "t_cells_05_08": Def(DT.int16, DT.deci, IR(77), min=-60.0, max=150.0),
        "t_cells_09_12": Def(DT.int16, DT.deci, IR(78), min=-60.0, max=150.0),
        "t_cells_13_16": Def(DT.int16, DT.deci, IR(79), min=-60.0, max=150.0),
        "v_cells_sum": Def(DT.milli, None, IR(80), min=16.0, max=80.0),
        "t_bms_mosfet": Def(DT.int16, DT.deci, IR(81), min=-60.0, max=150.0),
        "v_out": Def(DT.uint32, DT.milli, IR(82), IR(83), min=16.0, max=80.0),
        "cap_calibrated": Def(DT.uint32, DT.centi, IR(84), IR(85)),
        "cap_design": Def(DT.uint32, DT.centi, IR(86), IR(87)),
        "cap_remaining": Def(DT.uint32, DT.centi, IR(88), IR(89)),
        "status_1": Def((DT.duint8, 0), None, IR(90)),
        "status_2": Def((DT.duint8, 1), None, IR(90)),
        "status_3": Def((DT.duint8, 0), None, IR(91)),
        "status_4": Def((DT.duint8, 1), None, IR(91)),
        "status_5": Def((DT.duint8, 0), None, IR(92)),
        "status_6": Def((DT.duint8, 1), None, IR(92)),
        "status_7": Def((DT.duint8, 0), None, IR(93)),
        "warning_1": Def((DT.duint8, 0), None, IR(94)),
        "warning_2": Def((DT.duint8, 1), None, IR(94)),
        # IR(95) "Im_Avg" (v4.1.6 doc): average pack current, signed, 0.01 A.
        # Field evidence (#238, two LV systems) confirms it tracks operating state;
        # observed sign convention is +ve = discharge / -ve = charge.
        "i_battery": Def(DT.int16, DT.centi, IR(95), min=-300.0, max=300.0),
        "num_cycles": Def(DT.uint16, None, IR(96)),
        "num_cells": Def(DT.uint16, None, IR(97)),
        "bms_firmware_version": Def(DT.uint16, None, IR(98)),
        # IR(99) unused
        "soc": Def(DT.uint16, None, IR(100), min=0, max=100),
        "cap_design2": Def(DT.uint32, DT.centi, IR(101), IR(102)),
        "t_max": Def(DT.int16, DT.deci, IR(103), min=-60.0, max=150.0),
        "t_min": Def(DT.int16, DT.deci, IR(104), min=-60.0, max=150.0),
        # IR(105/106) "Battery discharge/charge energy total" (v4.1.6 doc): lifetime
        # totals, 0.1 kWh, unsigned. Field evidence (#238, two LV systems): both packs
        # within a plant report identical values — the counters look stack-level,
        # mirrored into each pack, so don't sum across packs. Single uint16, so they
        # wrap at 6553.5 kWh. Direction assignment follows the doc (and matches the
        # long-shipped GivTCP fork mapping); one observed plant had lifetime
        # discharge > charge, which remains unexplained. Firmware-gated (#241): packs
        # on BMS fw 3007/3009 read 0 here where fw 3022 populates both — a zero is
        # "not supported", not a true lifetime figure.
        "e_battery_discharge_total": Def(DT.deci, None, IR(105)),
        "e_battery_charge_total": Def(DT.deci, None, IR(106)),
        # IR(107) "Force_DisChg_Flag" (v4.1.6 doc): no unit or range documented; only 0
        # observed in the field so far (#241). Raw uint16 until semantics are known.
        "force_discharge_flag": Def(DT.uint16, None, IR(107)),
        # IR(108-109) unused
        "serial_number": Def(DT.serial, None, IR(110), IR(111), IR(112), IR(113), IR(114)),
        # IR(115) meaning unverified — manufacturer specs only document 0 and 8 (originally
        # decoded as a UsbDevice enum), but observed values outside that set (e.g. 11 on
        # D0.449-A0.449) caused decode failures. Exposed as a raw uint16 until documented.
        "usb_device_inserted": Def(DT.uint16, None, IR(115)),
        # IR(116-119) unused
    }

ExportPriority

Bases: IntEnum

Dispatch priority for surplus power on AC-coupled inverters.

HR(311) was identified as Export Priority via hass#52 wire captures. The integer↔label mapping is tester-confirmed (hass#218, #303): cycling the GE portal and reading the round-tripped raw value gave 0 = Load First, 1 = Battery First, 2 = Grid First. (The earlier provisional mapping had all three rotated — it guessed a 2↔0 swap, but the field data showed a full rotation.)

Source code in givenergy_modbus/model/battery.py
171
172
173
174
175
176
177
178
179
180
181
182
183
class ExportPriority(IntEnum):
    """Dispatch priority for surplus power on AC-coupled inverters.

    HR(311) was identified as Export Priority via hass#52 wire captures. The integer↔label mapping
    is tester-confirmed (hass#218, #303): cycling the GE portal and reading the round-tripped raw
    value gave ``0 = Load First``, ``1 = Battery First``, ``2 = Grid First``. (The earlier
    provisional mapping had all three rotated — it guessed a 2↔0 swap, but the field data showed a
    full rotation.)
    """

    LOAD_FIRST = 0
    BATTERY_FIRST = 1
    GRID_FIRST = 2

State

Bases: IntEnum

Battery charge/discharge state.

Source code in givenergy_modbus/model/battery.py
159
160
161
162
163
164
165
166
167
168
class State(IntEnum):
    """Battery charge/discharge state."""

    STATIC = 0
    CHARGE = 1
    DISCHARGE = 2

    @classmethod
    def _missing_(cls, value):
        return cls.STATIC

RegisterCache

Bases: defaultdict[Register, int]

Holds a cache of Registers populated after querying a device.

Source code in givenergy_modbus/model/register_cache.py
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
class RegisterCache(defaultdict[Register, int]):
    """Holds a cache of Registers populated after querying a device."""

    def __init__(self, registers: dict[Register, int] | None = None) -> None:
        if registers is None:
            registers = {}
        super().__init__(lambda: 0, registers)

    def json(self) -> str:
        """Return JSON representation of the register cache, to mirror `from_json()`.

        .. warning::
            This emits **unredacted** serial-number registers (and any other raw values).
            For a share-safe export, redact first: ``cache.redact_serials().json()``.
        """  # noqa: D402,D202,E501
        return json.dumps(self)

    @classmethod
    def from_json(cls, data: str) -> "RegisterCache":
        """Instantiate a RegisterCache from its JSON form."""

        def register_object_hook(object_dict: dict[str, int]) -> dict[Register, int]:
            """Rewrite the parsed object to have Register instances as keys instead of their (string) repr."""
            lookup = {"HR": HR, "IR": IR, "MR": MR}
            ret = {}
            for k, v in object_dict.items():
                if k.find("(") > 0:
                    reg, idx = k.split("(", maxsplit=1)
                    idx = idx[:-1]
                elif k.find(":") > 0:
                    reg, idx = k.split(":", maxsplit=1)
                else:
                    _logger.warning("Skipping unrecognised register key %r", k)
                    continue
                try:
                    register = lookup[reg](int(idx))
                    if v is None:
                        # None is the codebase's legitimate "unset" sentinel (e.g. a missing
                        # slot endpoint); preserve it so it round-trips through JSON.
                        value = None
                    elif isinstance(v, bool):
                        # bool is an int subclass, so 1 == True would slip past the range check;
                        # reject JSON true/false rather than silently storing it as 1/0 (M4).
                        raise ValueError(f"register value {v!r} is a bool, not an integer")
                    else:
                        value = int(v)
                        if value != v or not (0 <= value <= 0xFFFF):
                            # Fail closed: a register is an unsigned 16-bit word. A fractional
                            # number (silently truncated by int()) or an out-of-range value
                            # would later raise OverflowError in to_bytes() in a consumer (M4).
                            raise ValueError(f"register value {v!r} is not an unsigned 16-bit int")
                    ret[register] = value
                except (KeyError, ValueError, TypeError, OverflowError):
                    # KeyError: unknown register prefix (e.g. a future namespace we don't know
                    # about yet). ValueError: idx wasn't an int, or the value wasn't a coercible
                    # in-range integer (a string / bool / fractional / out-of-range value in a
                    # tampered cache JSON). TypeError: value was a non-scalar (list/dict).
                    # OverflowError: int(float("inf")) from a non-standard JSON Infinity. Skip the
                    # entry rather than aborting the load or storing a value that crashes a consumer.
                    _logger.warning("Skipping unloadable register entry %r=%r", k, v)
                    continue
            return ret

        return cls(registers=(json.loads(data, object_hook=register_object_hook)))

    # helper methods to convert register data types

    def to_string(self, *registers: Register) -> str:
        """Combine registers into an ASCII string."""
        s = "".join([self[r].to_bytes(2, byteorder="big").decode(encoding="latin1") for r in registers])
        return "".join(filter(str.isalnum, s)).upper()

    def to_hex_string(self, *registers: Register) -> str:
        """Render a register as a 2-byte hexadecimal value."""
        values = [f"{self[r]:04x}" for r in registers]
        if all(values):
            ret = ""
            for r in registers:
                ret += f"{self[r]:04x}"
            return "".join(filter(str.isalnum, ret)).upper()
        return ""

    def to_duint8(self, *registers: Register) -> tuple[int, ...]:
        """Split registers into two unsigned 8-bit integers each."""
        return sum(((self[r] >> 8, self[r] & 0xFF) for r in registers), ())

    def to_uint32(self, high_register: Register, low_register: Register) -> int:
        """Combine two registers into an unsigned 32-bit integer."""
        return (self[high_register] << 16) + self[low_register]

    def to_datetime(self, y: Register, m: Register, d: Register, h: Register, min: Register, s: Register):
        """Combine 6 registers into a datetime, with safe defaults for zeroes."""
        return datetime.datetime(self[y] + 2000, self.get(m, 1) or 1, self.get(d, 1) or 1, self[h], self[min], self[s])

    def redact_serials(self) -> "RegisterCache":
        """Return a copy of this cache with all known serial-number registers redacted.

        Identifies every register group tagged as ``Converter.serial`` in the model
        LUTs (plus BMU serial groups, which are decoded manually), decodes each fully-
        present group, and date-redacts values that match a known GE serial pattern
        (prefix + manufacture date kept, unit digits zeroed).

        **Fails open for HR/IR groups by necessity.** Serial groups are applied without
        device-type context and overlap: the BMU serial groups (e.g. IR(114-118)) are
        real serials only on HV BMU stacks, but on an LV battery those addresses hold the
        battery serial's last register (IR114) and ordinary data (IR115 = usb_device_inserted).
        With no way to tell a non-GE serial from non-serial data, anything that doesn't
        match a serial pattern is left **unchanged** — blanking it would destroy legitimate
        data and corrupt overlapping serials. The share-safe-export guarantee (#212/#214)
        is enforced fail-closed where it is unambiguous: the inverter/dongle header serials
        (:meth:`Plant.redact`) and the meter product identifier (MR, a distinct register
        namespace, blanked below).

        Produces the same ``AAYYWWA000``-style placeholders as :class:`FrameRedactor`,
        so a redacted export is indistinguishable from a redacted capture.
        """
        from givenergy_modbus.model.register import Converter

        _reg_cls: dict[str, type[Register]] = {"HR": HR, "IR": IR, "MR": MR}
        result = RegisterCache(dict(self))
        for reg_type, base, count in _get_serial_groups():
            reg_cls = _reg_cls.get(reg_type)
            if reg_cls is None:
                continue
            regs = [reg_cls(base + i) for i in range(count)]
            # Meter product identifier (MR): a short non-GE value in a distinct register namespace
            # that can't overlap HR/IR data — safe to fail closed. Blank whatever is present (a
            # full or partial fragment) before the HR/IR completeness check, without injecting
            # absent registers.
            if reg_type == "MR":
                for reg in regs:
                    if isinstance(self.get(reg), int):
                        result[reg] = 0
                continue
            if not all(isinstance(self.get(r), int) for r in regs):
                continue
            raw = b"".join((self[r] & 0xFFFF).to_bytes(2, "big") for r in regs)
            serial_str = raw.decode("latin1").replace("\x00", "").upper()
            redacted = Converter.redact_serial(serial_str)
            if redacted is None or redacted == serial_str:
                continue  # not a recognised serial — leave unchanged (may be non-serial data)
            redacted_bytes = redacted.encode("latin1").ljust(count * 2, b"\x00")[: count * 2]
            for i, reg in enumerate(regs):
                result[reg] = int.from_bytes(redacted_bytes[i * 2 : i * 2 + 2], "big")
        return result

    def to_timeslot(self, start: Register, end: Register) -> "TimeSlot | None":
        """Combine two registers into a time slot, or None if either is unset.

        Mirrors Converter.timeslot: a missing/None endpoint, or the raw value 60
        (a hardware sentinel for an unset slot — the portal shows '--:--'), means
        "unset". Both would otherwise raise ValueError in TimeSlot.from_repr.
        """
        from givenergy_modbus.model import TimeSlot

        start_val, end_val = self.get(start), self.get(end)
        if start_val is None or end_val is None or start_val == 60 or end_val == 60:
            return None
        return TimeSlot.from_repr(start_val, end_val)

from_json(data) classmethod

Instantiate a RegisterCache from its JSON form.

Source code in givenergy_modbus/model/register_cache.py
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
@classmethod
def from_json(cls, data: str) -> "RegisterCache":
    """Instantiate a RegisterCache from its JSON form."""

    def register_object_hook(object_dict: dict[str, int]) -> dict[Register, int]:
        """Rewrite the parsed object to have Register instances as keys instead of their (string) repr."""
        lookup = {"HR": HR, "IR": IR, "MR": MR}
        ret = {}
        for k, v in object_dict.items():
            if k.find("(") > 0:
                reg, idx = k.split("(", maxsplit=1)
                idx = idx[:-1]
            elif k.find(":") > 0:
                reg, idx = k.split(":", maxsplit=1)
            else:
                _logger.warning("Skipping unrecognised register key %r", k)
                continue
            try:
                register = lookup[reg](int(idx))
                if v is None:
                    # None is the codebase's legitimate "unset" sentinel (e.g. a missing
                    # slot endpoint); preserve it so it round-trips through JSON.
                    value = None
                elif isinstance(v, bool):
                    # bool is an int subclass, so 1 == True would slip past the range check;
                    # reject JSON true/false rather than silently storing it as 1/0 (M4).
                    raise ValueError(f"register value {v!r} is a bool, not an integer")
                else:
                    value = int(v)
                    if value != v or not (0 <= value <= 0xFFFF):
                        # Fail closed: a register is an unsigned 16-bit word. A fractional
                        # number (silently truncated by int()) or an out-of-range value
                        # would later raise OverflowError in to_bytes() in a consumer (M4).
                        raise ValueError(f"register value {v!r} is not an unsigned 16-bit int")
                ret[register] = value
            except (KeyError, ValueError, TypeError, OverflowError):
                # KeyError: unknown register prefix (e.g. a future namespace we don't know
                # about yet). ValueError: idx wasn't an int, or the value wasn't a coercible
                # in-range integer (a string / bool / fractional / out-of-range value in a
                # tampered cache JSON). TypeError: value was a non-scalar (list/dict).
                # OverflowError: int(float("inf")) from a non-standard JSON Infinity. Skip the
                # entry rather than aborting the load or storing a value that crashes a consumer.
                _logger.warning("Skipping unloadable register entry %r=%r", k, v)
                continue
        return ret

    return cls(registers=(json.loads(data, object_hook=register_object_hook)))

json()

Return JSON representation of the register cache, to mirror from_json().

.. warning:: This emits unredacted serial-number registers (and any other raw values). For a share-safe export, redact first: cache.redact_serials().json().

Source code in givenergy_modbus/model/register_cache.py
90
91
92
93
94
95
96
97
def json(self) -> str:
    """Return JSON representation of the register cache, to mirror `from_json()`.

    .. warning::
        This emits **unredacted** serial-number registers (and any other raw values).
        For a share-safe export, redact first: ``cache.redact_serials().json()``.
    """  # noqa: D402,D202,E501
    return json.dumps(self)

redact_serials()

Return a copy of this cache with all known serial-number registers redacted.

Identifies every register group tagged as Converter.serial in the model LUTs (plus BMU serial groups, which are decoded manually), decodes each fully- present group, and date-redacts values that match a known GE serial pattern (prefix + manufacture date kept, unit digits zeroed).

Fails open for HR/IR groups by necessity. Serial groups are applied without device-type context and overlap: the BMU serial groups (e.g. IR(114-118)) are real serials only on HV BMU stacks, but on an LV battery those addresses hold the battery serial's last register (IR114) and ordinary data (IR115 = usb_device_inserted). With no way to tell a non-GE serial from non-serial data, anything that doesn't match a serial pattern is left unchanged — blanking it would destroy legitimate data and corrupt overlapping serials. The share-safe-export guarantee (#212/#214) is enforced fail-closed where it is unambiguous: the inverter/dongle header serials (:meth:Plant.redact) and the meter product identifier (MR, a distinct register namespace, blanked below).

Produces the same AAYYWWA000-style placeholders as :class:FrameRedactor, so a redacted export is indistinguishable from a redacted capture.

Source code in givenergy_modbus/model/register_cache.py
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
def redact_serials(self) -> "RegisterCache":
    """Return a copy of this cache with all known serial-number registers redacted.

    Identifies every register group tagged as ``Converter.serial`` in the model
    LUTs (plus BMU serial groups, which are decoded manually), decodes each fully-
    present group, and date-redacts values that match a known GE serial pattern
    (prefix + manufacture date kept, unit digits zeroed).

    **Fails open for HR/IR groups by necessity.** Serial groups are applied without
    device-type context and overlap: the BMU serial groups (e.g. IR(114-118)) are
    real serials only on HV BMU stacks, but on an LV battery those addresses hold the
    battery serial's last register (IR114) and ordinary data (IR115 = usb_device_inserted).
    With no way to tell a non-GE serial from non-serial data, anything that doesn't
    match a serial pattern is left **unchanged** — blanking it would destroy legitimate
    data and corrupt overlapping serials. The share-safe-export guarantee (#212/#214)
    is enforced fail-closed where it is unambiguous: the inverter/dongle header serials
    (:meth:`Plant.redact`) and the meter product identifier (MR, a distinct register
    namespace, blanked below).

    Produces the same ``AAYYWWA000``-style placeholders as :class:`FrameRedactor`,
    so a redacted export is indistinguishable from a redacted capture.
    """
    from givenergy_modbus.model.register import Converter

    _reg_cls: dict[str, type[Register]] = {"HR": HR, "IR": IR, "MR": MR}
    result = RegisterCache(dict(self))
    for reg_type, base, count in _get_serial_groups():
        reg_cls = _reg_cls.get(reg_type)
        if reg_cls is None:
            continue
        regs = [reg_cls(base + i) for i in range(count)]
        # Meter product identifier (MR): a short non-GE value in a distinct register namespace
        # that can't overlap HR/IR data — safe to fail closed. Blank whatever is present (a
        # full or partial fragment) before the HR/IR completeness check, without injecting
        # absent registers.
        if reg_type == "MR":
            for reg in regs:
                if isinstance(self.get(reg), int):
                    result[reg] = 0
            continue
        if not all(isinstance(self.get(r), int) for r in regs):
            continue
        raw = b"".join((self[r] & 0xFFFF).to_bytes(2, "big") for r in regs)
        serial_str = raw.decode("latin1").replace("\x00", "").upper()
        redacted = Converter.redact_serial(serial_str)
        if redacted is None or redacted == serial_str:
            continue  # not a recognised serial — leave unchanged (may be non-serial data)
        redacted_bytes = redacted.encode("latin1").ljust(count * 2, b"\x00")[: count * 2]
        for i, reg in enumerate(regs):
            result[reg] = int.from_bytes(redacted_bytes[i * 2 : i * 2 + 2], "big")
    return result

to_datetime(y, m, d, h, min, s)

Combine 6 registers into a datetime, with safe defaults for zeroes.

Source code in givenergy_modbus/model/register_cache.py
172
173
174
def to_datetime(self, y: Register, m: Register, d: Register, h: Register, min: Register, s: Register):
    """Combine 6 registers into a datetime, with safe defaults for zeroes."""
    return datetime.datetime(self[y] + 2000, self.get(m, 1) or 1, self.get(d, 1) or 1, self[h], self[min], self[s])

to_duint8(*registers)

Split registers into two unsigned 8-bit integers each.

Source code in givenergy_modbus/model/register_cache.py
164
165
166
def to_duint8(self, *registers: Register) -> tuple[int, ...]:
    """Split registers into two unsigned 8-bit integers each."""
    return sum(((self[r] >> 8, self[r] & 0xFF) for r in registers), ())

to_hex_string(*registers)

Render a register as a 2-byte hexadecimal value.

Source code in givenergy_modbus/model/register_cache.py
154
155
156
157
158
159
160
161
162
def to_hex_string(self, *registers: Register) -> str:
    """Render a register as a 2-byte hexadecimal value."""
    values = [f"{self[r]:04x}" for r in registers]
    if all(values):
        ret = ""
        for r in registers:
            ret += f"{self[r]:04x}"
        return "".join(filter(str.isalnum, ret)).upper()
    return ""

to_string(*registers)

Combine registers into an ASCII string.

Source code in givenergy_modbus/model/register_cache.py
149
150
151
152
def to_string(self, *registers: Register) -> str:
    """Combine registers into an ASCII string."""
    s = "".join([self[r].to_bytes(2, byteorder="big").decode(encoding="latin1") for r in registers])
    return "".join(filter(str.isalnum, s)).upper()

to_timeslot(start, end)

Combine two registers into a time slot, or None if either is unset.

Mirrors Converter.timeslot: a missing/None endpoint, or the raw value 60 (a hardware sentinel for an unset slot — the portal shows '--:--'), means "unset". Both would otherwise raise ValueError in TimeSlot.from_repr.

Source code in givenergy_modbus/model/register_cache.py
228
229
230
231
232
233
234
235
236
237
238
239
240
def to_timeslot(self, start: Register, end: Register) -> "TimeSlot | None":
    """Combine two registers into a time slot, or None if either is unset.

    Mirrors Converter.timeslot: a missing/None endpoint, or the raw value 60
    (a hardware sentinel for an unset slot — the portal shows '--:--'), means
    "unset". Both would otherwise raise ValueError in TimeSlot.from_repr.
    """
    from givenergy_modbus.model import TimeSlot

    start_val, end_val = self.get(start), self.get(end)
    if start_val is None or end_val is None or start_val == 60 or end_val == 60:
        return None
    return TimeSlot.from_repr(start_val, end_val)

to_uint32(high_register, low_register)

Combine two registers into an unsigned 32-bit integer.

Source code in givenergy_modbus/model/register_cache.py
168
169
170
def to_uint32(self, high_register: Register, low_register: Register) -> int:
    """Combine two registers into an unsigned 32-bit integer."""
    return (self[high_register] << 16) + self[low_register]

parse_compact(text)

Parse a compact probe-dump back into device caches (inverse of :func:to_compact).

Lenient and order-agnostic — the input is human-pasted diagnostic text. Accepts the device-inline grammar emitted by :func:to_compact and (transitionally) the legacy header format. Hex reflowed across lines by copy-paste is reassembled; a row that still doesn't reach its declared length is skipped without aborting the rest. # comments (provenance), Probing … status, .. timed-out ranges and blank lines are ignored.

Source code in givenergy_modbus/model/register_cache.py
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
def parse_compact(text: str) -> "dict[int, RegisterCache]":
    """Parse a compact probe-dump back into device caches (inverse of :func:`to_compact`).

    Lenient and order-agnostic — the input is human-pasted diagnostic text. Accepts the
    device-inline grammar emitted by :func:`to_compact` and (transitionally) the legacy
    header format. Hex reflowed across lines by copy-paste is reassembled; a row that still
    doesn't reach its declared length is skipped without aborting the rest. ``#`` comments
    (provenance), ``Probing …`` status, ``..`` timed-out ranges and blank lines are ignored.
    """
    caches: dict[int, RegisterCache] = {}
    legacy_device: int | None = None
    lines = [line.strip() for line in text.splitlines()]
    i, n = 0, len(lines)
    while i < n:
        line = lines[i]
        i += 1

        header = _COMPACT_LEGACY_HEADER_RE.match(line)
        if header:
            legacy_device = int(header.group(1), 16)
            continue

        inline = _COMPACT_ROW_RE.match(line)
        legacy = _COMPACT_LEGACY_ROW_RE.match(line) if not inline else None
        if inline:
            device = int(inline.group(1), 16)
            bank, base, count, hexstr = inline.group(2), int(inline.group(3)), int(inline.group(4)), inline.group(5)
        elif legacy is not None:
            if legacy_device is None:
                _logger.warning("Skipping legacy compact row before any device header: %r", line)
                continue
            device = legacy_device
            bank, base, count, hexstr = legacy.group(1), int(legacy.group(2)), int(legacy.group(3)), legacy.group(4)
        else:
            continue  # comment / Probing… / `..` timed-out / blank / stray

        want = count * 4
        # Reassemble hex reflowed across lines: pull in following bare-hex continuation lines.
        while len(hexstr) < want and i < n and _COMPACT_HEX_RE.match(lines[i]):
            hexstr += lines[i]
            i += 1
        if len(hexstr) != want:
            _logger.warning("Skipping compact row with %d hex chars, expected %d: %r", len(hexstr), want, line)
            continue

        cls = _REG_CLS[bank]
        cache = caches.setdefault(device, RegisterCache())
        for j in range(count):
            cache[cls(base + j)] = int(hexstr[j * 4 : j * 4 + 4], 16)
    return caches

to_compact(caches)

Serialise device register caches to the compact hex probe-dump format.

Peer to :meth:RegisterCache.json — a pure str projection with no file I/O. Each row is self-describing::

0x32:HR(0,60) 0000000500ff…

0x<dev> is the device address, HR/IR/MR the bank, (<base>,<count>) the range, then count × 4 lowercase hex chars (one 16-bit register per 4). Blocks are split on the 60-register grid GivEnergy probes read on, so whole-block caches round-trip as clean, non-overlapping rows. Rows are emitted in (device, bank, base) order for stable output. Provenance (host:port) is the caller's to add as an ignored # comment.

Source code in givenergy_modbus/model/register_cache.py
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
def to_compact(caches: "dict[int, RegisterCache]") -> str:
    """Serialise device register caches to the compact hex probe-dump format.

    Peer to :meth:`RegisterCache.json` — a pure ``str`` projection with no file I/O. Each
    row is self-describing::

        0x32:HR(0,60) 0000000500ff…

    ``0x<dev>`` is the device address, ``HR``/``IR``/``MR`` the bank, ``(<base>,<count>)`` the
    range, then ``count × 4`` lowercase hex chars (one 16-bit register per 4). Blocks are split
    on the 60-register grid GivEnergy probes read on, so whole-block caches round-trip as clean,
    non-overlapping rows. Rows are emitted in (device, bank, base) order for stable output.
    Provenance (host:port) is the caller's to add as an ignored ``#`` comment.
    """
    lines: list[str] = []
    for device in sorted(caches):
        by_bank: dict[str, dict[int, int]] = {}
        for reg, val in caches[device].items():
            if isinstance(val, int) and not isinstance(val, bool):
                by_bank.setdefault(reg.reg_type, {})[reg.index] = val
        for bank in ("HR", "IR", "MR"):
            present = by_bank.get(bank)
            if not present:
                continue
            for base, count in _compact_blocks(sorted(present)):
                hexstr = "".join(f"{present[base + i] & 0xFFFF:04x}" for i in range(count))
                lines.append(f"0x{device:02x}:{bank}({base},{count}) {hexstr}")
    return "".join(line + "\n" for line in lines)

Data model.

DefaultUnknownIntEnum

Bases: IntEnum

Enum that returns unknown instead of blowing up.

Source code in givenergy_modbus/model/__init__.py
24
25
26
27
28
29
class DefaultUnknownIntEnum(IntEnum):
    """Enum that returns unknown instead of blowing up."""

    @classmethod
    def _missing_(cls, value):
        return cls.UNKNOWN  # type: ignore[attr-defined] # must be defined in subclasses because of Enum limits

GivEnergyBaseModel

Bases: BaseModel

Structured format for all other attributes.

Source code in givenergy_modbus/model/__init__.py
13
14
15
16
17
18
19
20
21
class GivEnergyBaseModel(BaseModel):
    """Structured format for all other attributes."""

    model_config = ConfigDict(frozen=True, use_enum_values=True)

    @classmethod
    def from_registers(cls, register_cache: "RegisterCache"):
        """Constructor parsing registers directly."""
        raise NotImplementedError()

from_registers(register_cache) classmethod

Constructor parsing registers directly.

Source code in givenergy_modbus/model/__init__.py
18
19
20
21
@classmethod
def from_registers(cls, register_cache: "RegisterCache"):
    """Constructor parsing registers directly."""
    raise NotImplementedError()

TimeSlot

Represents a time slot with a start and end time.

Source code in givenergy_modbus/model/__init__.py
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
class TimeSlot:
    """Represents a time slot with a start and end time."""

    def __init__(self, start: time, end: time) -> None:
        self.start = start
        self.end = end

    def __eq__(self, other: object) -> bool:
        return isinstance(other, TimeSlot) and self.start == other.start and self.end == other.end

    def __repr__(self) -> str:
        return f"TimeSlot(start={self.start!r}, end={self.end!r})"

    @classmethod
    def __get_pydantic_core_schema__(cls, source_type, handler):
        """Keep TimeSlot instances as-is in model_dump(mode='python')."""
        from pydantic_core import core_schema

        def _serialize(v, info):
            if hasattr(info, "mode") and info.mode != "python":
                return {"start": v.start, "end": v.end}
            return v

        return core_schema.no_info_plain_validator_function(
            lambda v: v if isinstance(v, cls) else cls(**v),
            serialization=core_schema.plain_serializer_function_ser_schema(
                _serialize,
                info_arg=True,
            ),
        )

    @classmethod
    def from_components(cls, start_hour: int, start_minute: int, end_hour: int, end_minute: int):
        """Shorthand for the individual datetime.time constructors."""
        return cls(time(start_hour, start_minute), time(end_hour, end_minute))

    @classmethod
    def from_repr(cls, start: int | str, end: int | str):
        """Converts from human-readable/ASCII representation: '0034' -> 00:34."""
        if isinstance(start, int):
            start = f"{start:04d}"
        start_hour = int(start[:-2])
        start_minute = int(start[-2:])
        if isinstance(end, int):
            end = f"{end:04d}"
        end_hour = int(end[:-2])
        end_minute = int(end[-2:])
        return cls(time(start_hour, start_minute), time(end_hour, end_minute))

__get_pydantic_core_schema__(source_type, handler) classmethod

Keep TimeSlot instances as-is in model_dump(mode='python').

Source code in givenergy_modbus/model/__init__.py
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
@classmethod
def __get_pydantic_core_schema__(cls, source_type, handler):
    """Keep TimeSlot instances as-is in model_dump(mode='python')."""
    from pydantic_core import core_schema

    def _serialize(v, info):
        if hasattr(info, "mode") and info.mode != "python":
            return {"start": v.start, "end": v.end}
        return v

    return core_schema.no_info_plain_validator_function(
        lambda v: v if isinstance(v, cls) else cls(**v),
        serialization=core_schema.plain_serializer_function_ser_schema(
            _serialize,
            info_arg=True,
        ),
    )

from_components(start_hour, start_minute, end_hour, end_minute) classmethod

Shorthand for the individual datetime.time constructors.

Source code in givenergy_modbus/model/__init__.py
63
64
65
66
@classmethod
def from_components(cls, start_hour: int, start_minute: int, end_hour: int, end_minute: int):
    """Shorthand for the individual datetime.time constructors."""
    return cls(time(start_hour, start_minute), time(end_hour, end_minute))

from_repr(start, end) classmethod

Converts from human-readable/ASCII representation: '0034' -> 00:34.

Source code in givenergy_modbus/model/__init__.py
68
69
70
71
72
73
74
75
76
77
78
79
@classmethod
def from_repr(cls, start: int | str, end: int | str):
    """Converts from human-readable/ASCII representation: '0034' -> 00:34."""
    if isinstance(start, int):
        start = f"{start:04d}"
    start_hour = int(start[:-2])
    start_minute = int(start[-2:])
    if isinstance(end, int):
        end = f"{end:04d}"
    end_hour = int(end[:-2])
    end_minute = int(end[-2:])
    return cls(time(start_hour, start_minute), time(end_hour, end_minute))

PDU

Package for the tree of PDU messages.

BasePDU

Bases: ABC

Base of the PDU Message network_timeout_handler class tree.

The Protocol Data Unit (PDU) defines the basic unit of message exchange for Modbus. It is routed to devices with specific addresses, and targets specific operations through function codes. This tree defines the hierarchy of functions, along with the attributes they specify and how they are encoded.

The tree branches at the top based on the directionality of the messages – either client-focused (messages a client should expect to receive and send) or server-focused (less important for this library, but messages that a server would emit and expect to receive). It is mirrored in that a Request message from a client would have a matching Response message the server should reply with.

The PDU classes are also codecs – they know how to convert between binary network frames and instantiated objects that can be manipulated programmatically.

Source code in givenergy_modbus/pdu/base.py
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
class BasePDU(ABC):
    """Base of the PDU Message network_timeout_handler class tree.

    The Protocol Data Unit (PDU) defines the basic unit of message exchange for Modbus. It is routed to devices with
    specific addresses, and targets specific operations through function codes. This tree defines the hierarchy of
    functions, along with the attributes they specify and how they are encoded.

    The tree branches at the top based on the directionality of the messages – either client-focused (messages a
    client should expect to receive and send) or server-focused (less important for this library, but messages that a
    server would emit and expect to receive). It is mirrored in that a Request message from a client would have a
    matching Response message the server should reply with.

    The PDU classes are also codecs – they know how to convert between binary network frames and instantiated objects
    that can be manipulated programmatically.
    """

    _builder: PayloadEncoder
    function_code: int
    data_adapter_serial_number: str = "AB1234G567"  # for client requests this seems ignored
    raw_frame: bytes

    def _set_attribute_if_present(self, attr: str, **kwargs):
        if attr in kwargs:
            setattr(self, attr, kwargs[attr])

    def __init__(self, **kwargs):
        self._set_attribute_if_present("data_adapter_serial_number", **kwargs)

    def encode(self) -> bytes:
        """Encode PDU message from instance attributes."""
        self.ensure_valid_state()
        self._builder = PayloadEncoder()
        self._builder.add_string(self.data_adapter_serial_number, 10)
        self._encode_function_data()
        # self._update_check_code()
        inner_frame = self._builder.payload
        mbap_header = struct.pack(">HHHBB", 0x5959, 0x1, len(inner_frame) + 2, 0x1, self.function_code)
        self.raw_frame = mbap_header + inner_frame
        return self.raw_frame

    @classmethod
    def decode_bytes(cls, data: bytes) -> "BasePDU":
        """Decode raw byte frame to populated PDU instance."""
        decoder = PayloadDecoder(data)

        # The whole decode — header parsing included — runs inside the boundary so it is
        # complete for *every* caller (not just the framer's outer catch). A sub-header
        # truncation (e.g. a direct `decode_bytes(b"")`) raises struct.error from the very
        # first `decode_16bit_uint()`; that and any other unexpected decode-time error
        # (a truncated payload, a converter ValueError) surface as InvalidFrame. The explicit
        # InvalidFrame header checks and InvalidPduState are re-raised verbatim (L6).
        try:
            t_id = decoder.decode_16bit_uint()
            if t_id != 0x5959:
                raise InvalidFrame(f"Transaction ID 0x{t_id:04x} != 0x5959", data)

            p_id = decoder.decode_16bit_uint()
            if p_id != 0x0001:
                raise InvalidFrame(f"Protocol ID 0x{p_id:04x} != 0x0001", data)

            header_len = decoder.decode_16bit_uint()
            remaining_frame_len = decoder.remaining_bytes  # includes 2 bytes for uid and function code
            if header_len != remaining_frame_len:
                raise InvalidFrame(f"Header length {header_len} != remaining frame length {remaining_frame_len}", data)

            u_id = decoder.decode_8bit_uint()
            if u_id not in (0x00, 0x01):
                raise InvalidFrame(f"Unit ID 0x{u_id:02x} != 0x00/0x01", data)

            function_code = decoder.decode_8bit_uint()
            decoder_class = cls.lookup_main_function_decoder(function_code)

            pdu = decoder_class.decode_main_function(decoder)
            pdu.raw_frame = data
            pdu.ensure_valid_state()
        except (InvalidFrame, InvalidPduState):
            raise
        except Exception as e:
            raise InvalidFrame(f"frame failed low-level decode: {type(e).__name__}: {e}", data)

        if not decoder.decoding_complete:
            _logger.error(
                f"Decoder did not fully consume frame for {pdu}: decoded {decoder.decoded_bytes}b but "
                f"packet header specified length={decoder.payload_size}. "
                f"Remaining payload: [{decoder.remaining_payload.hex()}]"
            )
        return pdu

    @classmethod
    def lookup_main_function_decoder(cls, function_code: int) -> type["BasePDU"]:
        raise NotImplementedError()

    @classmethod
    def decode_main_function(cls, decoder: PayloadDecoder, **attrs) -> "BasePDU":
        raise NotImplementedError()

    def _encode_function_data(self) -> None:
        """Complete function-specific encoding of the remainder of the PDU message."""
        raise NotImplementedError()

    def ensure_valid_state(self) -> None:
        """Sanity check our internal state."""
        raise NotImplementedError()

    def has_same_shape(self, o: object):
        """Calculates whether a given message has the "same shape".

        Messages are similarly shaped when they match message type (response, error state), location (device address,
        register type, register indexes) etc. but not data / register values.

        This is not an identity check but could be used both for creating template expected responses from
        outgoing requests (to facilitate tracking future responses), but also allows incoming messages to be
        hashed consistently to avoid (e.g.) multiple messages of the same shape getting enqueued unnecessarily –
        the theory being that newer messages being enqueued might as well replace older ones of the same shape.
        """
        if isinstance(o, BasePDU):
            return self.shape_hash() == o.shape_hash()
        raise NotImplementedError()

    def shape_hash(self) -> int:
        """Calculates the "shape hash" for a given message."""
        return hash(self._shape_hash_keys())

    def _shape_hash_keys(self) -> tuple:
        """Defines which keys to compare to see if two messages have the same shape."""
        return (type(self), self.function_code) + self._extra_shape_hash_keys()

    def _extra_shape_hash_keys(self) -> tuple:
        """Allows extra message-specific keys to be mixed in."""
        raise NotImplementedError()

decode_bytes(data) classmethod

Decode raw byte frame to populated PDU instance.

Source code in givenergy_modbus/pdu/base.py
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
@classmethod
def decode_bytes(cls, data: bytes) -> "BasePDU":
    """Decode raw byte frame to populated PDU instance."""
    decoder = PayloadDecoder(data)

    # The whole decode — header parsing included — runs inside the boundary so it is
    # complete for *every* caller (not just the framer's outer catch). A sub-header
    # truncation (e.g. a direct `decode_bytes(b"")`) raises struct.error from the very
    # first `decode_16bit_uint()`; that and any other unexpected decode-time error
    # (a truncated payload, a converter ValueError) surface as InvalidFrame. The explicit
    # InvalidFrame header checks and InvalidPduState are re-raised verbatim (L6).
    try:
        t_id = decoder.decode_16bit_uint()
        if t_id != 0x5959:
            raise InvalidFrame(f"Transaction ID 0x{t_id:04x} != 0x5959", data)

        p_id = decoder.decode_16bit_uint()
        if p_id != 0x0001:
            raise InvalidFrame(f"Protocol ID 0x{p_id:04x} != 0x0001", data)

        header_len = decoder.decode_16bit_uint()
        remaining_frame_len = decoder.remaining_bytes  # includes 2 bytes for uid and function code
        if header_len != remaining_frame_len:
            raise InvalidFrame(f"Header length {header_len} != remaining frame length {remaining_frame_len}", data)

        u_id = decoder.decode_8bit_uint()
        if u_id not in (0x00, 0x01):
            raise InvalidFrame(f"Unit ID 0x{u_id:02x} != 0x00/0x01", data)

        function_code = decoder.decode_8bit_uint()
        decoder_class = cls.lookup_main_function_decoder(function_code)

        pdu = decoder_class.decode_main_function(decoder)
        pdu.raw_frame = data
        pdu.ensure_valid_state()
    except (InvalidFrame, InvalidPduState):
        raise
    except Exception as e:
        raise InvalidFrame(f"frame failed low-level decode: {type(e).__name__}: {e}", data)

    if not decoder.decoding_complete:
        _logger.error(
            f"Decoder did not fully consume frame for {pdu}: decoded {decoder.decoded_bytes}b but "
            f"packet header specified length={decoder.payload_size}. "
            f"Remaining payload: [{decoder.remaining_payload.hex()}]"
        )
    return pdu

encode()

Encode PDU message from instance attributes.

Source code in givenergy_modbus/pdu/base.py
39
40
41
42
43
44
45
46
47
48
49
def encode(self) -> bytes:
    """Encode PDU message from instance attributes."""
    self.ensure_valid_state()
    self._builder = PayloadEncoder()
    self._builder.add_string(self.data_adapter_serial_number, 10)
    self._encode_function_data()
    # self._update_check_code()
    inner_frame = self._builder.payload
    mbap_header = struct.pack(">HHHBB", 0x5959, 0x1, len(inner_frame) + 2, 0x1, self.function_code)
    self.raw_frame = mbap_header + inner_frame
    return self.raw_frame

ensure_valid_state()

Sanity check our internal state.

Source code in givenergy_modbus/pdu/base.py
111
112
113
def ensure_valid_state(self) -> None:
    """Sanity check our internal state."""
    raise NotImplementedError()

has_same_shape(o)

Calculates whether a given message has the "same shape".

Messages are similarly shaped when they match message type (response, error state), location (device address, register type, register indexes) etc. but not data / register values.

This is not an identity check but could be used both for creating template expected responses from outgoing requests (to facilitate tracking future responses), but also allows incoming messages to be hashed consistently to avoid (e.g.) multiple messages of the same shape getting enqueued unnecessarily – the theory being that newer messages being enqueued might as well replace older ones of the same shape.

Source code in givenergy_modbus/pdu/base.py
115
116
117
118
119
120
121
122
123
124
125
126
127
128
def has_same_shape(self, o: object):
    """Calculates whether a given message has the "same shape".

    Messages are similarly shaped when they match message type (response, error state), location (device address,
    register type, register indexes) etc. but not data / register values.

    This is not an identity check but could be used both for creating template expected responses from
    outgoing requests (to facilitate tracking future responses), but also allows incoming messages to be
    hashed consistently to avoid (e.g.) multiple messages of the same shape getting enqueued unnecessarily –
    the theory being that newer messages being enqueued might as well replace older ones of the same shape.
    """
    if isinstance(o, BasePDU):
        return self.shape_hash() == o.shape_hash()
    raise NotImplementedError()

shape_hash()

Calculates the "shape hash" for a given message.

Source code in givenergy_modbus/pdu/base.py
130
131
132
def shape_hash(self) -> int:
    """Calculates the "shape hash" for a given message."""
    return hash(self._shape_hash_keys())

ClientIncomingMessage

Bases: BasePDU, ABC

Root of the hierarchy for PDUs clients are expected to receive and handle.

Source code in givenergy_modbus/pdu/base.py
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
class ClientIncomingMessage(BasePDU, ABC):
    """Root of the hierarchy for PDUs clients are expected to receive and handle."""

    @classmethod
    def lookup_main_function_decoder(cls, function_code: int) -> type["ClientIncomingMessage"]:
        from givenergy_modbus.pdu import HeartbeatRequest, TransparentResponse

        if function_code == 1:
            return HeartbeatRequest
        elif function_code == 2:
            return TransparentResponse
        else:
            raise NotImplementedError(f"ClientIncomingMessage main function #{function_code} decoder")

    def expected_response(self) -> "ClientOutgoingMessage | None":
        """Create a template of a correctly shaped Response expected for this Request."""
        raise NotImplementedError()

expected_response()

Create a template of a correctly shaped Response expected for this Request.

Source code in givenergy_modbus/pdu/base.py
157
158
159
def expected_response(self) -> "ClientOutgoingMessage | None":
    """Create a template of a correctly shaped Response expected for this Request."""
    raise NotImplementedError()

ClientOutgoingMessage

Bases: BasePDU, ABC

Root of the hierarchy for PDUs clients are expected to send to servers.

Source code in givenergy_modbus/pdu/base.py
162
163
164
165
166
167
168
169
170
171
172
173
174
class ClientOutgoingMessage(BasePDU, ABC):
    """Root of the hierarchy for PDUs clients are expected to send to servers."""

    @classmethod
    def lookup_main_function_decoder(cls, function_code: int) -> type["ClientOutgoingMessage"]:
        from givenergy_modbus.pdu import HeartbeatResponse, TransparentRequest

        if function_code == 1:
            return HeartbeatResponse
        elif function_code == 2:
            return TransparentRequest
        else:
            raise NotImplementedError(f"ClientOutgoingMessage main function #{function_code} decoder")

HeartbeatMessage

Bases: BasePDU, ABC

Root of the hierarchy for 1/Heartbeat function PDUs.

Source code in givenergy_modbus/pdu/heartbeat.py
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
class HeartbeatMessage(BasePDU, ABC):
    """Root of the hierarchy for 1/Heartbeat function PDUs."""

    function_code = 1
    data_adapter_type: int

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.data_adapter_type: int = kwargs.get("data_adapter_type", 0x00)

    def __str__(self) -> str:
        return (
            f"1/{self.__class__.__name__}("
            f"data_adapter_serial_number={self.data_adapter_serial_number!r} "
            f"data_adapter_type={self.data_adapter_type})"
        )

    def _encode_function_data(self):
        """Encode request PDU message and populate instance attributes."""
        self._builder.add_8bit_uint(self.data_adapter_type)

    def _decode_function_data(self, decoder):
        """Encode request PDU message and populate instance attributes."""
        self.data_adapter_type = decoder.decode_8bit_uint()

    @classmethod
    def decode_main_function(cls, decoder: PayloadDecoder, **attrs) -> "HeartbeatMessage":
        attrs["data_adapter_serial_number"] = decoder.decode_string(10)
        attrs["data_adapter_type"] = decoder.decode_8bit_uint()
        return cls(**attrs)

    def ensure_valid_state(self):
        """Sanity check our internal state."""

    def _update_check_code(self):
        pass

    def _extra_shape_hash_keys(self) -> tuple:
        """Allows extra message-specific keys to be mixed in."""
        return (self.data_adapter_type,)

ensure_valid_state()

Sanity check our internal state.

Source code in givenergy_modbus/pdu/heartbeat.py
41
42
def ensure_valid_state(self):
    """Sanity check our internal state."""

HeartbeatRequest

Bases: HeartbeatMessage, ClientIncomingMessage, ABC

PDU sent by remote server to check liveness of client.

Source code in givenergy_modbus/pdu/heartbeat.py
52
53
54
55
56
57
class HeartbeatRequest(HeartbeatMessage, ClientIncomingMessage, ABC):
    """PDU sent by remote server to check liveness of client."""

    def expected_response(self) -> "HeartbeatResponse":
        """Create an appropriate response for an incoming HeartbeatRequest."""
        return HeartbeatResponse(data_adapter_type=self.data_adapter_type)

expected_response()

Create an appropriate response for an incoming HeartbeatRequest.

Source code in givenergy_modbus/pdu/heartbeat.py
55
56
57
def expected_response(self) -> "HeartbeatResponse":
    """Create an appropriate response for an incoming HeartbeatRequest."""
    return HeartbeatResponse(data_adapter_type=self.data_adapter_type)

HeartbeatResponse

Bases: HeartbeatMessage, ClientOutgoingMessage, ABC

PDU returned by client (within 5s) to confirm liveness.

Source code in givenergy_modbus/pdu/heartbeat.py
60
61
62
63
64
65
66
67
68
69
70
71
class HeartbeatResponse(HeartbeatMessage, ClientOutgoingMessage, ABC):
    """PDU returned by client (within 5s) to confirm liveness."""

    def decode(self, data: bytes):
        """Decode response PDU message and populate instance attributes."""
        decoder = PayloadDecoder(data)
        self.data_adapter_serial_number = decoder.decode_string(10)
        self.data_adapter_type = decoder.decode_8bit_uint()
        _logger.debug(f"Successfully decoded {len(data)} bytes")

    def expected_response(self) -> None:
        """No replies expected for HeartbeatResponse."""

decode(data)

Decode response PDU message and populate instance attributes.

Source code in givenergy_modbus/pdu/heartbeat.py
63
64
65
66
67
68
def decode(self, data: bytes):
    """Decode response PDU message and populate instance attributes."""
    decoder = PayloadDecoder(data)
    self.data_adapter_serial_number = decoder.decode_string(10)
    self.data_adapter_type = decoder.decode_8bit_uint()
    _logger.debug(f"Successfully decoded {len(data)} bytes")

expected_response()

No replies expected for HeartbeatResponse.

Source code in givenergy_modbus/pdu/heartbeat.py
70
71
def expected_response(self) -> None:
    """No replies expected for HeartbeatResponse."""

LanConfigBroadcast

Bases: ClientIncomingMessage

Dongle LAN-configuration broadcast (function 0x02 / CSV body).

Some WO-prefix inverter dongles periodically broadcast their network configuration as a function-code 2 frame whose body is:

adapter_serial[10]  6_zeros[6]  null[1]  ,<ip>,<netmask>,<gateway>\r\n\r\n  check[2]

The standard transparent decoder reads the null byte as transparent_function_code (0x30 / '0') and bails to InvalidFrame. This class intercepts those frames at decode time before the transparent path is attempted (discriminator: remaining_payload[6]0 and remaining_payload[7]',').

Refs: #100 (original discovery), #158 (B-3 redactor).

Source code in givenergy_modbus/pdu/lan_config.py
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
class LanConfigBroadcast(ClientIncomingMessage):
    r"""Dongle LAN-configuration broadcast (function 0x02 / CSV body).

    Some WO-prefix inverter dongles periodically broadcast their network
    configuration as a function-code 2 frame whose body is:

        adapter_serial[10]  6_zeros[6]  null[1]  ,<ip>,<netmask>,<gateway>\r\n\r\n  check[2]

    The standard transparent decoder reads the null byte as
    transparent_function_code (0x30 / '0') and bails to InvalidFrame. This class
    intercepts those frames at decode time before the transparent path is attempted
    (discriminator: remaining_payload[6]==0 and remaining_payload[7]==',').

    Refs: #100 (original discovery), #158 (B-3 redactor).
    """

    function_code = 2

    # Offsets within remaining_payload *after* data_adapter_serial_number is consumed:
    # remaining[0:6] = 6 zero bytes, remaining[6] = 0x00, remaining[7] = 0x2c (',')
    _DISC_NULL_OFFSET = 6
    _DISC_COMMA = 0x2C  # ord(',')
    _PAD_LEN = 7  # 6 zeros + 1 null

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.ip: str = kwargs.get("ip", "")
        self.netmask: str = kwargs.get("netmask", "")
        self.gateway: str = kwargs.get("gateway", "")
        self.check: int = kwargs.get("check", 0)
        self._csv_raw: bytes = kwargs.get("_csv_raw", b"")

    @classmethod
    def is_lan_config(cls, remaining_after_serial: bytes) -> bool:
        """Return True if the remaining decoder bytes look like a LAN config broadcast."""
        return (
            len(remaining_after_serial) >= 8
            # The 6 bytes preceding the null+comma are the zero padding of a real broadcast;
            # requiring them avoids a crafted padding field false-positiving and dropping a
            # valid transparent response (L6).
            and not any(remaining_after_serial[: cls._DISC_NULL_OFFSET])
            and remaining_after_serial[cls._DISC_NULL_OFFSET] == 0x00
            and remaining_after_serial[cls._DISC_NULL_OFFSET + 1] == cls._DISC_COMMA
        )

    @classmethod
    def decode_main_function(cls, decoder: PayloadDecoder, **attrs) -> "LanConfigBroadcast":
        """Called by TransparentMessage.decode_main_function after reading the serial.

        `attrs` already contains `data_adapter_serial_number`. Consume the 7 padding bytes
        then parse the CSV from the remaining payload.
        """
        # Consume the 7 padding bytes (6 zeros + 1 null)
        for _ in range(cls._PAD_LEN):
            decoder.decode_8bit_uint()
        remaining = decoder.remaining_payload
        csv_raw = remaining[:-2]
        check_val = int.from_bytes(remaining[-2:], "big")
        # Consume all remaining bytes so decoding_complete is satisfied
        decoder.decode_string(decoder.remaining_bytes)
        # Parse CSV: strip leading comma, split on comma, strip trailing \r\n
        csv_str = csv_raw.decode("latin1").lstrip(",").rstrip("\r\n")
        parts = csv_str.split(",")
        return cls(
            data_adapter_serial_number=attrs.get("data_adapter_serial_number", ""),
            ip=parts[0] if len(parts) > 0 else "",
            netmask=parts[1] if len(parts) > 1 else "",
            gateway=parts[2] if len(parts) > 2 else "",
            check=check_val,
            _csv_raw=csv_raw,
        )

    def _encode_function_data(self) -> None:
        # BasePDU.encode() has already written data_adapter_serial_number (10 bytes).
        # Write: 7 padding bytes + csv_raw + check(2)
        for _ in range(self._PAD_LEN):
            self._builder.add_8bit_uint(0)
        self._builder._payload += self._csv_raw
        self._builder.add_16bit_uint(self.check)

    def encode(self) -> bytes:
        """Re-encode to wire bytes; length-preserving. CRC is not recomputed."""
        from givenergy_modbus.codec import PayloadEncoder

        self._builder = PayloadEncoder()
        self._builder.add_string(self.data_adapter_serial_number, 10)
        self._encode_function_data()
        inner = self._builder.payload
        mbap = struct.pack(">HHHBB", 0x5959, 0x1, len(inner) + 2, 0x1, self.function_code)
        self.raw_frame = mbap + inner
        return self.raw_frame

    def ensure_valid_state(self) -> None:
        """No state validation required for LAN-config broadcast frames."""

    def expected_response(self):
        """No response expected for LAN-config broadcasts."""
        return None

    def _extra_shape_hash_keys(self) -> tuple:
        return ()

    @classmethod
    def lookup_main_function_decoder(cls, function_code: int) -> "type[ClientIncomingMessage]":
        """Not used — LanConfigBroadcast is decoded directly, not via lookup."""
        raise NotImplementedError()

    def redact(self) -> "LanConfigBroadcast":
        """Return a new instance with the adapter serial and all IP fields zeroed.

        The trailing 2-byte ``check`` field is carried through verbatim. Its
        derivation for this non-standard frame type is unknown — verified to not
        follow the ``CRC16/Modbus(payload[18:], byte-swapped)`` scheme used by
        all other GivEnergy frames (no candidate span produces a match). This is
        consistent with the fact that real captures arrive with the IPs already
        zeroed and a ``check`` value computed by the dongle over those zeroed bytes;
        redaction of a live frame would leave the check inconsistent with the new CSV
        bytes, but without understanding the formula it cannot be corrected. A comment
        in the test documents the implication.
        """
        from givenergy_modbus.model.register import Converter

        redacted_serial = Converter.redact_serial(self.data_adapter_serial_number) or ""
        redacted_ip = _zero_ip(self.ip)
        redacted_netmask = _zero_ip(self.netmask)
        redacted_gateway = _zero_ip(self.gateway)
        new_csv_raw = f",{redacted_ip},{redacted_netmask},{redacted_gateway}\r\n\r\n".encode("latin1")
        return LanConfigBroadcast(
            data_adapter_serial_number=redacted_serial,
            ip=redacted_ip,
            netmask=redacted_netmask,
            gateway=redacted_gateway,
            check=self.check,  # opaque: derivation unknown for this frame type
            _csv_raw=new_csv_raw,
        )

decode_main_function(decoder, **attrs) classmethod

Called by TransparentMessage.decode_main_function after reading the serial.

attrs already contains data_adapter_serial_number. Consume the 7 padding bytes then parse the CSV from the remaining payload.

Source code in givenergy_modbus/pdu/lan_config.py
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
@classmethod
def decode_main_function(cls, decoder: PayloadDecoder, **attrs) -> "LanConfigBroadcast":
    """Called by TransparentMessage.decode_main_function after reading the serial.

    `attrs` already contains `data_adapter_serial_number`. Consume the 7 padding bytes
    then parse the CSV from the remaining payload.
    """
    # Consume the 7 padding bytes (6 zeros + 1 null)
    for _ in range(cls._PAD_LEN):
        decoder.decode_8bit_uint()
    remaining = decoder.remaining_payload
    csv_raw = remaining[:-2]
    check_val = int.from_bytes(remaining[-2:], "big")
    # Consume all remaining bytes so decoding_complete is satisfied
    decoder.decode_string(decoder.remaining_bytes)
    # Parse CSV: strip leading comma, split on comma, strip trailing \r\n
    csv_str = csv_raw.decode("latin1").lstrip(",").rstrip("\r\n")
    parts = csv_str.split(",")
    return cls(
        data_adapter_serial_number=attrs.get("data_adapter_serial_number", ""),
        ip=parts[0] if len(parts) > 0 else "",
        netmask=parts[1] if len(parts) > 1 else "",
        gateway=parts[2] if len(parts) > 2 else "",
        check=check_val,
        _csv_raw=csv_raw,
    )

encode()

Re-encode to wire bytes; length-preserving. CRC is not recomputed.

Source code in givenergy_modbus/pdu/lan_config.py
 97
 98
 99
100
101
102
103
104
105
106
107
def encode(self) -> bytes:
    """Re-encode to wire bytes; length-preserving. CRC is not recomputed."""
    from givenergy_modbus.codec import PayloadEncoder

    self._builder = PayloadEncoder()
    self._builder.add_string(self.data_adapter_serial_number, 10)
    self._encode_function_data()
    inner = self._builder.payload
    mbap = struct.pack(">HHHBB", 0x5959, 0x1, len(inner) + 2, 0x1, self.function_code)
    self.raw_frame = mbap + inner
    return self.raw_frame

ensure_valid_state()

No state validation required for LAN-config broadcast frames.

Source code in givenergy_modbus/pdu/lan_config.py
109
110
def ensure_valid_state(self) -> None:
    """No state validation required for LAN-config broadcast frames."""

expected_response()

No response expected for LAN-config broadcasts.

Source code in givenergy_modbus/pdu/lan_config.py
112
113
114
def expected_response(self):
    """No response expected for LAN-config broadcasts."""
    return None

is_lan_config(remaining_after_serial) classmethod

Return True if the remaining decoder bytes look like a LAN config broadcast.

Source code in givenergy_modbus/pdu/lan_config.py
49
50
51
52
53
54
55
56
57
58
59
60
@classmethod
def is_lan_config(cls, remaining_after_serial: bytes) -> bool:
    """Return True if the remaining decoder bytes look like a LAN config broadcast."""
    return (
        len(remaining_after_serial) >= 8
        # The 6 bytes preceding the null+comma are the zero padding of a real broadcast;
        # requiring them avoids a crafted padding field false-positiving and dropping a
        # valid transparent response (L6).
        and not any(remaining_after_serial[: cls._DISC_NULL_OFFSET])
        and remaining_after_serial[cls._DISC_NULL_OFFSET] == 0x00
        and remaining_after_serial[cls._DISC_NULL_OFFSET + 1] == cls._DISC_COMMA
    )

lookup_main_function_decoder(function_code) classmethod

Not used — LanConfigBroadcast is decoded directly, not via lookup.

Source code in givenergy_modbus/pdu/lan_config.py
119
120
121
122
@classmethod
def lookup_main_function_decoder(cls, function_code: int) -> "type[ClientIncomingMessage]":
    """Not used — LanConfigBroadcast is decoded directly, not via lookup."""
    raise NotImplementedError()

redact()

Return a new instance with the adapter serial and all IP fields zeroed.

The trailing 2-byte check field is carried through verbatim. Its derivation for this non-standard frame type is unknown — verified to not follow the CRC16/Modbus(payload[18:], byte-swapped) scheme used by all other GivEnergy frames (no candidate span produces a match). This is consistent with the fact that real captures arrive with the IPs already zeroed and a check value computed by the dongle over those zeroed bytes; redaction of a live frame would leave the check inconsistent with the new CSV bytes, but without understanding the formula it cannot be corrected. A comment in the test documents the implication.

Source code in givenergy_modbus/pdu/lan_config.py
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
def redact(self) -> "LanConfigBroadcast":
    """Return a new instance with the adapter serial and all IP fields zeroed.

    The trailing 2-byte ``check`` field is carried through verbatim. Its
    derivation for this non-standard frame type is unknown — verified to not
    follow the ``CRC16/Modbus(payload[18:], byte-swapped)`` scheme used by
    all other GivEnergy frames (no candidate span produces a match). This is
    consistent with the fact that real captures arrive with the IPs already
    zeroed and a ``check`` value computed by the dongle over those zeroed bytes;
    redaction of a live frame would leave the check inconsistent with the new CSV
    bytes, but without understanding the formula it cannot be corrected. A comment
    in the test documents the implication.
    """
    from givenergy_modbus.model.register import Converter

    redacted_serial = Converter.redact_serial(self.data_adapter_serial_number) or ""
    redacted_ip = _zero_ip(self.ip)
    redacted_netmask = _zero_ip(self.netmask)
    redacted_gateway = _zero_ip(self.gateway)
    new_csv_raw = f",{redacted_ip},{redacted_netmask},{redacted_gateway}\r\n\r\n".encode("latin1")
    return LanConfigBroadcast(
        data_adapter_serial_number=redacted_serial,
        ip=redacted_ip,
        netmask=redacted_netmask,
        gateway=redacted_gateway,
        check=self.check,  # opaque: derivation unknown for this frame type
        _csv_raw=new_csv_raw,
    )

NullResponse

Bases: TransparentResponse

Concrete PDU implementation for handling function #0/Null Response messages.

This seems to be a quirk of the GivEnergy implementation – from time to time these responses will be sent unprompted by the remote device and this just handles it gracefully and allows further debugging. The function data payload seems to be invariably just a series of nulls.

Source code in givenergy_modbus/pdu/null.py
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
class NullResponse(TransparentResponse):
    """Concrete PDU implementation for handling function #0/Null Response messages.

    This seems to be a quirk of the GivEnergy implementation – from time to time these responses will be sent
    unprompted by the remote device and this just handles it gracefully and allows further debugging. The function
    data payload seems to be invariably just a series of nulls.
    """

    transparent_function_code = 0

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.nulls = kwargs.get("nulls", [0] * 62)

    def _encode_function_data(self) -> None:
        super()._encode_function_data()
        [self._builder.add_16bit_uint(v) for v in self.nulls]
        self._update_check_code()

    @classmethod
    def decode_transparent_function(cls, decoder: PayloadDecoder, **attrs) -> "NullResponse":
        if decoder.remaining_bytes < 126:
            # 62 nulls + check = 126 bytes; a shorter frame would overrun the decoder
            # (struct.error) mid-loop. Fail the frame cleanly instead (L6).
            raise InvalidFrame(f"Null frame too short: {decoder.remaining_bytes}b < 126b", decoder.remaining_payload)
        if decoder.remaining_bytes != 126:
            _logger.warning(
                f"remaining bytes: {decoder.remaining_bytes}b 0x{decoder.remaining_payload.hex()} attrs: {attrs}"
            )
        attrs["nulls"] = [decoder.decode_16bit_uint() for _ in range(62)]
        attrs["check"] = decoder.decode_16bit_uint()
        return cls(**attrs)

    def expected_response(self):
        """No response expected."""

    def ensure_valid_state(self) -> None:
        """Sanity check our internal state."""
        if self.inverter_serial_number != "\x00" * 10:
            hex_str = self.inverter_serial_number.encode("latin1").hex()
            _logger.warning(f"Unexpected non-null inverter serial number: {self.inverter_serial_number!r}/0x{hex_str}")
        if any(self.nulls):
            _logger.warning(
                f'Unexpected non-null "register" values: {dict(filter(lambda v: v[1] != 0, enumerate(self.nulls)))}'
            )

    def _extra_shape_hash_keys(self) -> tuple:
        return ()

ensure_valid_state()

Sanity check our internal state.

Source code in givenergy_modbus/pdu/null.py
46
47
48
49
50
51
52
53
54
def ensure_valid_state(self) -> None:
    """Sanity check our internal state."""
    if self.inverter_serial_number != "\x00" * 10:
        hex_str = self.inverter_serial_number.encode("latin1").hex()
        _logger.warning(f"Unexpected non-null inverter serial number: {self.inverter_serial_number!r}/0x{hex_str}")
    if any(self.nulls):
        _logger.warning(
            f'Unexpected non-null "register" values: {dict(filter(lambda v: v[1] != 0, enumerate(self.nulls)))}'
        )

expected_response()

No response expected.

Source code in givenergy_modbus/pdu/null.py
43
44
def expected_response(self):
    """No response expected."""

ReadHoldingRegisters

Bases: ReadRegistersMessage, ABC

Request & Response PDUs for function #3/Read Holding Registers.

Source code in givenergy_modbus/pdu/read_registers.py
194
195
196
197
class ReadHoldingRegisters(ReadRegistersMessage, ABC):
    """Request & Response PDUs for function #3/Read Holding Registers."""

    transparent_function_code = 3

ReadHoldingRegistersRequest

Bases: ReadHoldingRegisters, ReadRegistersRequest

Concrete PDU implementation for handling function #3/Read Holding Registers request messages.

Source code in givenergy_modbus/pdu/read_registers.py
200
201
202
203
204
205
206
class ReadHoldingRegistersRequest(ReadHoldingRegisters, ReadRegistersRequest):
    """Concrete PDU implementation for handling function #3/Read Holding Registers request messages."""

    def expected_response(self):
        return ReadHoldingRegistersResponse(
            base_register=self.base_register, register_count=self.register_count, device_address=self.device_address
        )

ReadHoldingRegistersResponse

Bases: ReadHoldingRegisters, ReadRegistersResponse

Concrete PDU implementation for handling function #3/Read Holding Registers response messages.

Source code in givenergy_modbus/pdu/read_registers.py
209
210
211
212
213
class ReadHoldingRegistersResponse(ReadHoldingRegisters, ReadRegistersResponse):
    """Concrete PDU implementation for handling function #3/Read Holding Registers response messages."""

    def expected_response(self):
        return

ReadInputRegisters

Bases: ReadRegistersMessage, ABC

Request & Response PDUs for function #4/Read Input Registers.

Source code in givenergy_modbus/pdu/read_registers.py
216
217
218
219
class ReadInputRegisters(ReadRegistersMessage, ABC):
    """Request & Response PDUs for function #4/Read Input Registers."""

    transparent_function_code = 4

ReadInputRegistersRequest

Bases: ReadInputRegisters, ReadRegistersRequest

Concrete PDU implementation for handling function #4/Read Input Registers request messages.

Source code in givenergy_modbus/pdu/read_registers.py
222
223
224
225
226
227
228
class ReadInputRegistersRequest(ReadInputRegisters, ReadRegistersRequest):
    """Concrete PDU implementation for handling function #4/Read Input Registers request messages."""

    def expected_response(self):
        return ReadInputRegistersResponse(
            base_register=self.base_register, register_count=self.register_count, device_address=self.device_address
        )

ReadInputRegistersResponse

Bases: ReadInputRegisters, ReadRegistersResponse

Concrete PDU implementation for handling function #4/Read Input Registers response messages.

Source code in givenergy_modbus/pdu/read_registers.py
231
232
233
234
235
class ReadInputRegistersResponse(ReadInputRegisters, ReadRegistersResponse):
    """Concrete PDU implementation for handling function #4/Read Input Registers response messages."""

    def expected_response(self):
        return

ReadMeterProductRegisters

Bases: ReadRegistersMessage, ABC

Request & Response PDUs for function #0x16/Read Meter Product Registers.

Source code in givenergy_modbus/pdu/read_registers.py
238
239
240
241
class ReadMeterProductRegisters(ReadRegistersMessage, ABC):
    """Request & Response PDUs for function #0x16/Read Meter Product Registers."""

    transparent_function_code = 0x16

ReadMeterProductRegistersRequest

Bases: ReadMeterProductRegisters, ReadRegistersRequest

Concrete PDU implementation for handling function #0x16/Read Meter Product Registers request messages.

Source code in givenergy_modbus/pdu/read_registers.py
244
245
246
247
248
249
250
class ReadMeterProductRegistersRequest(ReadMeterProductRegisters, ReadRegistersRequest):
    """Concrete PDU implementation for handling function #0x16/Read Meter Product Registers request messages."""

    def expected_response(self):
        return ReadMeterProductRegistersResponse(
            base_register=self.base_register, register_count=self.register_count, device_address=self.device_address
        )

ReadMeterProductRegistersResponse

Bases: ReadMeterProductRegisters, ReadRegistersResponse

Concrete PDU implementation for handling function #0x16/Read Meter Product Registers response messages.

Source code in givenergy_modbus/pdu/read_registers.py
257
258
259
260
261
class ReadMeterProductRegistersResponse(ReadMeterProductRegisters, ReadRegistersResponse):
    """Concrete PDU implementation for handling function #0x16/Read Meter Product Registers response messages."""

    def expected_response(self):
        return

ReadRegistersMessage

Bases: TransparentMessage, ABC

Mixin for commands that specify base register and register count semantics.

Source code in givenergy_modbus/pdu/read_registers.py
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
class ReadRegistersMessage(TransparentMessage, ABC):
    """Mixin for commands that specify base register and register count semantics."""

    base_register: int
    register_count: int

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.base_register = kwargs.get("base_register", 0)
        self.register_count = kwargs.get("register_count", 0)

    @classmethod
    def decode_transparent_function(cls, decoder: PayloadDecoder, **attrs) -> "ReadRegistersMessage":
        attrs["base_register"] = decoder.decode_16bit_uint()
        attrs["register_count"] = decoder.decode_16bit_uint()
        if issubclass(cls, ReadRegistersResponse) and not attrs.get("error", False):
            # Cap to 60 to prevent buffer exhaustion from a crafted register_count.
            # ensure_valid_state will reject the count/values length mismatch.
            decode_count = min(attrs["register_count"], 60)
            attrs["register_values"] = [decoder.decode_16bit_uint() for _ in range(decode_count)]
        attrs["check"] = decoder.decode_16bit_uint()
        return cls(**attrs)

    def _extra_shape_hash_keys(self) -> tuple:
        return super()._extra_shape_hash_keys() + (self.base_register, self.register_count)

    def _ensure_registers_spec_correct(self):
        if self.base_register is None:
            raise InvalidPduState("Base register must be set", self)
        if self.base_register < 0 or 0xFFFF < self.base_register:
            raise InvalidPduState("Base register must be an unsigned 16-bit int", self)

        if self.register_count is None:
            raise InvalidPduState("Register count must be set", self)
        if self.register_count == 0 and not self.error:
            _logger.warning(f"Register count of 0 does not make sense: {self}")

ReadRegistersRequest

Bases: ReadRegistersMessage, TransparentRequest, ABC

Handles all messages that request a range of registers.

Source code in givenergy_modbus/pdu/read_registers.py
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
class ReadRegistersRequest(ReadRegistersMessage, TransparentRequest, ABC):
    """Handles all messages that request a range of registers."""

    def _encode_function_data(self):
        super()._encode_function_data()
        self._builder.add_16bit_uint(self.base_register)
        self._builder.add_16bit_uint(self.register_count)
        self._update_check_code()  # unified CRC lives on TransparentMessage

    def ensure_valid_state(self):
        """Sanity check our internal state."""
        self._ensure_registers_spec_correct()

        # The 1000+ three-phase and 1600+ gateway banks intentionally use non-60-aligned
        # bases (range(1000, 1414, 60) etc.) — confirmed against the GivEnergy Android app
        # which sends the same pattern. Warning removed as it fired on every legitimate
        # three-phase poll, producing noise rather than signal. (#163)
        if self.register_count <= 0 or 60 < self.register_count:
            raise InvalidPduState("Register count must be in (0,60]", self)

ensure_valid_state()

Sanity check our internal state.

Source code in givenergy_modbus/pdu/read_registers.py
61
62
63
64
65
66
67
68
69
70
def ensure_valid_state(self):
    """Sanity check our internal state."""
    self._ensure_registers_spec_correct()

    # The 1000+ three-phase and 1600+ gateway banks intentionally use non-60-aligned
    # bases (range(1000, 1414, 60) etc.) — confirmed against the GivEnergy Android app
    # which sends the same pattern. Warning removed as it fired on every legitimate
    # three-phase poll, producing noise rather than signal. (#163)
    if self.register_count <= 0 or 60 < self.register_count:
        raise InvalidPduState("Register count must be in (0,60]", self)

ReadRegistersResponse

Bases: ReadRegistersMessage, TransparentResponse, ABC

Handles all messages that respond with a range of registers.

Source code in givenergy_modbus/pdu/read_registers.py
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
class ReadRegistersResponse(ReadRegistersMessage, TransparentResponse, ABC):
    """Handles all messages that respond with a range of registers."""

    #: Opt-in strict CRC enforcement. Set to True to raise :class:`InvalidPduState` on a CRC
    #: mismatch rather than skipping the commit. Useful for consumers with retry logic that want
    #: explicit error propagation. Takes precedence over :attr:`lenient_crc_commit`.
    strict_crc: ClassVar[bool] = False

    #: Opt-in lenient commit. Set to True to accept and commit CRC-failed frames — intended for
    #: frame-capture / forensic tools (which want every frame regardless of CRC) and the edge
    #: case of a dongle that consistently emits bad CRCs on otherwise-valid frames. Default is
    #: False: CRC-failed frames are logged at WARNING and then discarded (not committed to cache).
    #: Corpus evidence: 3,106 register response frames, 4 CRC failures, all wire=0x0000
    #: (zero-filled tail); zero non-zero CRC mismatches — the "bad CRC, valid data" scenario
    #: has never been observed in the field.
    lenient_crc_commit: ClassVar[bool] = False

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.register_values: list[int] = kwargs.get("register_values", [])
        self.crc_failed: bool = False

    def _encode_function_data(self):
        super()._encode_function_data()
        self._builder.add_16bit_uint(self.base_register)
        self._builder.add_16bit_uint(self.register_count)
        [self._builder.add_16bit_uint(v) for v in self.register_values]
        self._update_check_code()

    def ensure_valid_state(self) -> None:
        """Sanity check our internal state."""
        self._ensure_registers_spec_correct()

        if not self.error:
            # if self.register_count != 1 and self.base_register % 60 != 0:
            #     _logger.warning(f'Base register {self.base_register} not aligned on 60-byte boundary')
            if self.register_count != len(self.register_values):
                raise InvalidPduState(
                    f"register_count={self.register_count} but len(register_values)={len(self.register_values)}.",
                    self,
                )

        expected_padding = 0x12 if self.error else 0x8A
        if self.padding != expected_padding:
            _logger.debug(f"Expected padding 0x{expected_padding:02x}, found 0x{self.padding:02x} instead: {self}")

        self._validate_check_code()

    def _validate_check_code(self) -> None:
        """CRC check of a decoded response against the received bytes.

        Recomputes the unified CRC (CRC16/Modbus over `raw_frame[26:-2]` — the
        device-address byte onward, mirroring `TransparentMessage._update_check_code`'s
        `payload[18:]`, byte-swapped) and compares to the decoded `check`. Confirmed valid
        for 3,106 register response frames across all fixture captures (#158: 102/102,
        incl. error responses; 4 CRC failures total, all wire=0x0000 / zero-filled tail).

        On mismatch, sets :attr:`crc_failed` to True and logs at WARNING. Three outcomes:

        - ``strict_crc=True``: raises :class:`InvalidPduState` (commit never happens).
        - ``lenient_crc_commit=False`` (default): sets flag, logs, returns — ``Plant.update``
          will skip the commit, preserving last-good cache data.
        - ``lenient_crc_commit=True`` (opt-in): sets flag, logs, returns — commit is allowed.

        Only runs when ``raw_frame`` is present (i.e. on decoded frames).
        """
        raw_frame = getattr(self, "raw_frame", None)
        if not raw_frame or len(raw_frame) < 28:
            return
        computed = CrcModbus().process(raw_frame[26:-2]).final()
        expected = ((computed & 0xFF) << 8) | ((computed >> 8) & 0xFF)
        if expected != self.check:
            self.crc_failed = True
            if self.strict_crc:
                raise InvalidPduState(
                    f"Response failed CRC integrity check: "
                    f"wire=0x{self.check:04x} computed=0x{expected:04x} — frame corrupted or "
                    f"malformed in transit (strict_crc enabled)",
                    self,
                )
            _logger.warning(
                f"Response failed CRC integrity check on {self}: "
                f"wire=0x{self.check:04x} computed=0x{expected:04x} — "
                f"{'commit allowed (lenient_crc_commit)' if self.lenient_crc_commit else 'commit skipped'}"
            )

    def to_dict(self) -> dict[int, int]:
        """Return the registers as a dict of register_index:value. Accounts for base_register offsets."""
        return {k: v for k, v in enumerate(self.register_values, start=self.base_register)}

    def is_suspicious(self) -> bool:
        """Try to identify known-bad data in register lookup calls and prevent them from entering the dispatching."""
        if self.base_register % 60 == 0 and self.register_count == 60 and len(self.register_values) == 60:
            count_known_bad_register_values = (
                self.register_values[28] == 0x4C32,
                self.register_values[30] == 0xA119,
                self.register_values[31] == 0x34EA,
                self.register_values[32] == 0xE77F,
                self.register_values[33] == 0xD475,
                self.register_values[35] == 0x4500,
                self.register_values[40] in (0xE4F9, 0xB619),
                self.register_values[41] == 0xC0A8,
                self.register_values[43] == 0xC0A8,
                self.register_values[46] == 0xC5E9,
                self.register_values[50] in (0x60EF, 0x503C),
                self.register_values[51] == 0x8018,
                self.register_values[52] == 0x43E0,
                self.register_values[53] == 0xF6CE,
                self.register_values[56] == 0x080A,
                self.register_values[58] == 0xFCC1,
                self.register_values[59] == 0x661E,
            ).count(True)
            if count_known_bad_register_values > 5:
                _logger.debug(
                    f"Ignoring known suspicious update with {count_known_bad_register_values} known bad "
                    f"register values {self}: {self.to_dict()}"
                )
                return True
        return False

ensure_valid_state()

Sanity check our internal state.

Source code in givenergy_modbus/pdu/read_registers.py
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
def ensure_valid_state(self) -> None:
    """Sanity check our internal state."""
    self._ensure_registers_spec_correct()

    if not self.error:
        # if self.register_count != 1 and self.base_register % 60 != 0:
        #     _logger.warning(f'Base register {self.base_register} not aligned on 60-byte boundary')
        if self.register_count != len(self.register_values):
            raise InvalidPduState(
                f"register_count={self.register_count} but len(register_values)={len(self.register_values)}.",
                self,
            )

    expected_padding = 0x12 if self.error else 0x8A
    if self.padding != expected_padding:
        _logger.debug(f"Expected padding 0x{expected_padding:02x}, found 0x{self.padding:02x} instead: {self}")

    self._validate_check_code()

is_suspicious()

Try to identify known-bad data in register lookup calls and prevent them from entering the dispatching.

Source code in givenergy_modbus/pdu/read_registers.py
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
def is_suspicious(self) -> bool:
    """Try to identify known-bad data in register lookup calls and prevent them from entering the dispatching."""
    if self.base_register % 60 == 0 and self.register_count == 60 and len(self.register_values) == 60:
        count_known_bad_register_values = (
            self.register_values[28] == 0x4C32,
            self.register_values[30] == 0xA119,
            self.register_values[31] == 0x34EA,
            self.register_values[32] == 0xE77F,
            self.register_values[33] == 0xD475,
            self.register_values[35] == 0x4500,
            self.register_values[40] in (0xE4F9, 0xB619),
            self.register_values[41] == 0xC0A8,
            self.register_values[43] == 0xC0A8,
            self.register_values[46] == 0xC5E9,
            self.register_values[50] in (0x60EF, 0x503C),
            self.register_values[51] == 0x8018,
            self.register_values[52] == 0x43E0,
            self.register_values[53] == 0xF6CE,
            self.register_values[56] == 0x080A,
            self.register_values[58] == 0xFCC1,
            self.register_values[59] == 0x661E,
        ).count(True)
        if count_known_bad_register_values > 5:
            _logger.debug(
                f"Ignoring known suspicious update with {count_known_bad_register_values} known bad "
                f"register values {self}: {self.to_dict()}"
            )
            return True
    return False

to_dict()

Return the registers as a dict of register_index:value. Accounts for base_register offsets.

Source code in givenergy_modbus/pdu/read_registers.py
159
160
161
def to_dict(self) -> dict[int, int]:
    """Return the registers as a dict of register_index:value. Accounts for base_register offsets."""
    return {k: v for k, v in enumerate(self.register_values, start=self.base_register)}

TransparentMessage

Bases: BasePDU, ABC

Root of the hierarchy for 2/Transparent PDUs.

Source code in givenergy_modbus/pdu/transparent.py
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
class TransparentMessage(BasePDU, ABC):
    """Root of the hierarchy for 2/Transparent PDUs."""

    function_code = 2
    transparent_function_code: int

    device_address: int
    error: bool
    padding: int
    check: int

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        if "slave_address" in kwargs:
            if "device_address" in kwargs:
                raise TypeError("pass either device_address= or slave_address=, not both")
            warnings.warn(_SLAVE_ADDRESS_DEPRECATION_MSG, DeprecationWarning, stacklevel=2)
            kwargs["device_address"] = kwargs.pop("slave_address")
        self.device_address = kwargs.get("device_address", 0x32)
        self.error = kwargs.get("error", False)
        self.padding = kwargs.get("padding", 0x08)  # this does seem significant
        self.check = kwargs.get("check", 0x0000)

    @property
    def slave_address(self) -> int:
        """Deprecated alias for `device_address`."""
        warnings.warn(_SLAVE_ADDRESS_DEPRECATION_MSG, DeprecationWarning, stacklevel=2)
        return self.device_address

    @slave_address.setter
    def slave_address(self, value: int) -> None:
        warnings.warn(_SLAVE_ADDRESS_DEPRECATION_MSG, DeprecationWarning, stacklevel=2)
        self.device_address = value

    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)
        _logger.debug(f"TransparentMessage.__init_subclass__({cls.__name__})")

    def __str__(self) -> str:
        def format_kv(key, val):
            if val is None:
                val = "?"
            elif key == "device_address":
                # if val == 0x32:
                #     return None
                val = f"0x{val:02x}"
            elif key == "register_count" and val == 60:
                return None
            # elif key in ('check', 'padding'):
            #     val = f'0x{val:04x}'
            # elif key == 'raw_frame':
            #     return f'raw_frame={len(val)}b'
            elif key == "nulls":
                return f"nulls=[0]*{len(val)}"
            elif key in (
                "inverter_serial_number",
                "data_adapter_serial_number",
                "error",
                "check",
                "padding",
                "register_values",
                "raw_frame",
                "_builder",
                "crc_failed",
            ):
                return None
            return f"{key}={val}"

        args = []
        if self.error:
            args += ["ERROR"]
        args += [format_kv(k, v) for k, v in vars(self).items()]

        return (
            f"{self.function_code}:{getattr(self, 'transparent_function_code', '_')}/"
            f"{self.__class__.__name__}({' '.join([a for a in args if a is not None])})"
        )

    def _encode_function_data(self):
        self._builder.add_64bit_uint(self.padding)
        self._builder.add_8bit_uint(self.device_address)
        # The high bit of the transparent function code is the error flag (decode strips
        # it into `self.error`). Re-add it on encode so error responses round-trip — without
        # this, a decoded error response re-encodes as a malformed "success" frame (the bug
        # that silently corrupted fixture error frames during the #158 CRC regen).
        self._builder.add_8bit_uint(self.transparent_function_code | (0x80 if self.error else 0))
        # self._update_check_code()

    @classmethod
    def decode_main_function(cls, decoder: PayloadDecoder, **attrs) -> "TransparentMessage | BasePDU":
        from givenergy_modbus.pdu.lan_config import LanConfigBroadcast

        attrs["data_adapter_serial_number"] = decoder.decode_string(10)

        # LAN-config broadcast discriminator: some WO-prefix dongles emit a function-0x02
        # frame whose body is adapter_serial + 7-byte pad + ",ip,netmask,gateway\r\n\r\n" + check.
        # The 7th pad byte is 0x00 (not a valid transparent_function_code) and the next
        # byte is ',' — nothing in the normal transparent protocol can produce that sequence.
        if LanConfigBroadcast.is_lan_config(decoder.remaining_payload):
            return LanConfigBroadcast.decode_main_function(decoder, **attrs)

        attrs["padding"] = decoder.decode_64bit_uint()
        attrs["device_address"] = decoder.decode_8bit_uint()
        transparent_function_code = decoder.decode_8bit_uint()
        if transparent_function_code & 0x80:
            error = True
            transparent_function_code &= 0x7F
        else:
            error = False
        attrs["error"] = error

        if issubclass(cls, TransparentResponse):
            attrs["inverter_serial_number"] = decoder.decode_string(10)

        decoder_class = cls.lookup_transparent_function_decoder(transparent_function_code)
        return decoder_class.decode_transparent_function(decoder, **attrs)

    @classmethod
    def lookup_transparent_function_decoder(cls, transparent_function_code: int) -> type["TransparentMessage"]:
        raise NotImplementedError()

    @classmethod
    def decode_transparent_function(cls, decoder: PayloadDecoder, **attrs) -> "TransparentMessage":
        raise NotImplementedError()

    def ensure_valid_state(self) -> None:  # flake8: D102
        """Sanity check our internal state."""
        # if self.padding != 0x8A:
        #     _logger.debug(f'Expected padding 0x8a, found 0x{self.padding:02x} instead')

    def _update_check_code(self) -> None:
        """Append the trailing CRC over the already-built payload.

        One scheme for the entire Transparent protocol — every request *and* response
        type. CRC16/Modbus over the buffer from the device-address byte onward (skipping
        the 10-byte data-adapter serial + 8-byte padding = `payload[18:]`), byte-swapped
        on the wire. Operating on the built buffer rather than re-listing fields is what
        keeps request and response CRCs from drifting apart (the bug behind #105/#158).

        Confirmed against real GivTCP + GivEnergy-app request frames (#105:
        ReadHolding(0x11,0,60) → 0x474b) and the real All-in-One response corpus (#158:
        102/102 wire frames valid, incl. error responses).
        """
        raw = CrcModbus().process(self._builder.payload[18:]).final()
        self.check = ((raw & 0xFF) << 8) | ((raw >> 8) & 0xFF)
        self._builder.add_16bit_uint(self.check)

    def _extra_shape_hash_keys(self):
        return (self.device_address,)

slave_address property writable

Deprecated alias for device_address.

ensure_valid_state()

Sanity check our internal state.

Source code in givenergy_modbus/pdu/transparent.py
142
143
def ensure_valid_state(self) -> None:  # flake8: D102
    """Sanity check our internal state."""

TransparentRequest

Bases: TransparentMessage, ClientOutgoingMessage, ABC

Root of the hierarchy for Transparent Request PDUs.

Source code in givenergy_modbus/pdu/transparent.py
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
class TransparentRequest(TransparentMessage, ClientOutgoingMessage, ABC):
    """Root of the hierarchy for Transparent Request PDUs."""

    @classmethod
    def lookup_transparent_function_decoder(cls, transparent_function_code: int) -> type["TransparentRequest"]:
        from givenergy_modbus.pdu import (
            ReadHoldingRegistersRequest,
            ReadInputRegistersRequest,
            ReadMeterProductRegistersRequest,
            WriteHoldingRegisterRequest,
        )

        if transparent_function_code == 3:
            return ReadHoldingRegistersRequest
        elif transparent_function_code == 4:
            return ReadInputRegistersRequest
        elif transparent_function_code == 6:
            return WriteHoldingRegisterRequest
        elif transparent_function_code == 0x16:
            return ReadMeterProductRegistersRequest
        else:
            raise NotImplementedError(f"TransparentRequest function #{transparent_function_code} decoder")

    def expected_response(self) -> "TransparentResponse":
        """Create a template of a correctly shaped Response expected for this Request."""
        raise NotImplementedError()

expected_response()

Create a template of a correctly shaped Response expected for this Request.

Source code in givenergy_modbus/pdu/transparent.py
191
192
193
def expected_response(self) -> "TransparentResponse":
    """Create a template of a correctly shaped Response expected for this Request."""
    raise NotImplementedError()

TransparentResponse

Bases: TransparentMessage, ClientIncomingMessage, ABC

Root of the hierarchy for Transparent Response PDUs.

Source code in givenergy_modbus/pdu/transparent.py
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
class TransparentResponse(TransparentMessage, ClientIncomingMessage, ABC):
    """Root of the hierarchy for Transparent Response PDUs."""

    inverter_serial_number: str

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self._set_attribute_if_present("inverter_serial_number", **kwargs)

    def _encode_function_data(self):
        super()._encode_function_data()
        self._builder.add_string(self.inverter_serial_number, 10)

    @classmethod
    def lookup_transparent_function_decoder(cls, transparent_function_code: int) -> type["TransparentResponse"]:
        from givenergy_modbus.pdu import (
            NullResponse,
            ReadHoldingRegistersResponse,
            ReadInputRegistersResponse,
            ReadMeterProductRegistersResponse,
            WriteHoldingRegisterResponse,
        )

        if transparent_function_code == 0:
            return NullResponse
        elif transparent_function_code == 3:
            return ReadHoldingRegistersResponse
        elif transparent_function_code == 4:
            return ReadInputRegistersResponse
        elif transparent_function_code == 6:
            return WriteHoldingRegisterResponse
        elif transparent_function_code == 0x16:
            return ReadMeterProductRegistersResponse
        else:
            raise NotImplementedError(f"TransparentResponse function #{transparent_function_code} decoder")

WriteHoldingRegister

Bases: TransparentMessage, ABC

Request & Response PDUs for function #6/Write Holding Register.

Source code in givenergy_modbus/pdu/write_registers.py
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
class WriteHoldingRegister(TransparentMessage, ABC):
    """Request & Response PDUs for function #6/Write Holding Register."""

    transparent_function_code = 6

    register: int
    value: int

    def __init__(self, register: int, value: int, *args, **kwargs):
        if len(args) == 2:
            kwargs["register"] = args[0]
            kwargs["value"] = args[1]
        # WriteHoldingRegister defaults to 0x11 (the inverter's setup address) rather than the
        # 0x32 inherited from TransparentMessage. Only fill the default if neither alias was
        # supplied; the base __init__ handles slave_address→device_address mapping and warning.
        if "device_address" not in kwargs and "slave_address" not in kwargs:
            kwargs["device_address"] = 0x11
        super().__init__(**kwargs)
        if not isinstance(register, int):
            raise ValueError(f"Register type {type(register)} is unacceptable")
        self.register = register
        # bool subclasses int, so it would pass the isinstance check below and silently write
        # 0/1 — a bool reaching a numeric register (e.g. ACTIVE_POWER_RATE) is a caller bug.
        # Boolean command helpers pass int(enabled) explicitly (audit L1).
        if isinstance(value, bool) or not isinstance(value, int):
            raise ValueError(f"Register value {type(value)} is unacceptable")
        self.value = value

    def __str__(self) -> str:
        if self.register is not None and self.value is not None:
            return (
                f"{self.function_code}:{self.transparent_function_code}/{self.__class__.__name__}"
                f"({'ERROR ' if self.error else ''}{self.register} -> "
                f"{self.value}/0x{self.value:04x})"
            )
        else:
            return super().__str__()

    def __eq__(self, o: object) -> bool:
        return (
            isinstance(o, type(self))
            and self.has_same_shape(o)
            and o.register == self.register
            and o.value == self.value
            and o.error == self.error
        )

    def _encode_function_data(self):
        super()._encode_function_data()
        self._builder.add_16bit_uint(self.register)
        self._builder.add_16bit_uint(self.value)
        self._update_check_code()

    @classmethod
    def decode_transparent_function(cls, decoder: PayloadDecoder, **attrs) -> "WriteHoldingRegister":
        attrs["register"] = decoder.decode_16bit_uint()
        attrs["value"] = decoder.decode_16bit_uint()
        attrs["check"] = decoder.decode_16bit_uint()
        return cls(**attrs)

    def _extra_shape_hash_keys(self) -> tuple:
        return super()._extra_shape_hash_keys() + (self.register,)

    def ensure_valid_state(self):
        """Sanity check our internal state."""
        super().ensure_valid_state()
        if self.register is None:
            raise InvalidPduState("Register must be set", self)
        if self.value is None:
            raise InvalidPduState("Register value must be set", self)
        elif self.value < 0 or self.value > 0xFFFF:
            raise InvalidPduState(f"Value {self.value}/0x{self.value:04x} must be an unsigned 16-bit int", self)

ensure_valid_state()

Sanity check our internal state.

Source code in givenergy_modbus/pdu/write_registers.py
397
398
399
400
401
402
403
404
405
def ensure_valid_state(self):
    """Sanity check our internal state."""
    super().ensure_valid_state()
    if self.register is None:
        raise InvalidPduState("Register must be set", self)
    if self.value is None:
        raise InvalidPduState("Register value must be set", self)
    elif self.value < 0 or self.value > 0xFFFF:
        raise InvalidPduState(f"Value {self.value}/0x{self.value:04x} must be an unsigned 16-bit int", self)

WriteHoldingRegisterRequest

Bases: WriteHoldingRegister, TransparentRequest

Concrete PDU implementation for handling function #6/Write Holding Register request messages.

Source code in givenergy_modbus/pdu/write_registers.py
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
class WriteHoldingRegisterRequest(WriteHoldingRegister, TransparentRequest):
    """Concrete PDU implementation for handling function #6/Write Holding Register request messages."""

    # Non-wire class-level default: installer=False is the normal path.
    # Only set as an instance attribute when True so that wire-decoded instances
    # (built via decode_transparent_function without the kwarg) leave installer
    # absent from __dict__, which keeps the existing __dict__ equality tests intact.
    installer: bool = False

    def __init__(self, register: int, value: int, installer: bool = False, **kwargs):
        super().__init__(register, value, **kwargs)
        if installer:
            self.installer = installer

    def ensure_valid_state(self):
        """Sanity check our internal state."""
        super().ensure_valid_state()
        if self.installer:
            if self.register not in INSTALLER_WRITE_REGISTERS:
                raise InvalidPduState(f"HR({self.register}) is not in the installer register set", self)
        elif self.register not in WRITE_SAFE_REGISTERS:
            raise InvalidPduState(f"HR({self.register}) is not safe to write to", self)

    def expected_response(self):
        return WriteHoldingRegisterResponse(
            register=self.register, value=self.value, device_address=self.device_address
        )

ensure_valid_state()

Sanity check our internal state.

Source code in givenergy_modbus/pdu/write_registers.py
422
423
424
425
426
427
428
429
def ensure_valid_state(self):
    """Sanity check our internal state."""
    super().ensure_valid_state()
    if self.installer:
        if self.register not in INSTALLER_WRITE_REGISTERS:
            raise InvalidPduState(f"HR({self.register}) is not in the installer register set", self)
    elif self.register not in WRITE_SAFE_REGISTERS:
        raise InvalidPduState(f"HR({self.register}) is not safe to write to", self)

WriteHoldingRegisterResponse

Bases: WriteHoldingRegister, TransparentResponse

Concrete PDU implementation for handling function #6/Write Holding Register response messages.

Source code in givenergy_modbus/pdu/write_registers.py
437
438
439
440
441
442
443
444
445
class WriteHoldingRegisterResponse(WriteHoldingRegister, TransparentResponse):
    """Concrete PDU implementation for handling function #6/Write Holding Register response messages."""

    def ensure_valid_state(self):
        """Sanity check our internal state."""
        super().ensure_valid_state()
        known = WRITE_SAFE_REGISTERS | INSTALLER_WRITE_REGISTERS
        if self.register not in known and not self.error:
            _logger.warning(f"{self} is not safe for writing")

ensure_valid_state()

Sanity check our internal state.

Source code in givenergy_modbus/pdu/write_registers.py
440
441
442
443
444
445
def ensure_valid_state(self):
    """Sanity check our internal state."""
    super().ensure_valid_state()
    known = WRITE_SAFE_REGISTERS | INSTALLER_WRITE_REGISTERS
    if self.register not in known and not self.error:
        _logger.warning(f"{self} is not safe for writing")

Codec

PayloadDecoder

Decoder to unpack a raw binary payload into sequential typed fields.

Source code in givenergy_modbus/codec.py
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
class PayloadDecoder:
    """Decoder to unpack a raw binary payload into sequential typed fields."""

    _byteorder = ">"  # big-endian

    def __init__(self, payload: bytes):
        self._payload = payload
        self._pointer = 0

    # def __str__(self, *args):
    #     return f"?? {args}"
    #
    # @property
    # def string(self):
    #     return self.__str__

    def decode_8bit_uint(self):
        """Decodes an 8-bit unsigned int from the buffer."""
        self._pointer += 1
        handle = self._payload[self._pointer - 1 : self._pointer]
        return struct.unpack(self._byteorder + "B", handle)[0]

    def decode_16bit_uint(self):
        """Decodes a 16-bit unsigned int from the buffer."""
        self._pointer += 2
        handle = self._payload[self._pointer - 2 : self._pointer]
        return struct.unpack(self._byteorder + "H", handle)[0]

    def decode_32bit_uint(self):
        """Decodes a 32-bit unsigned int from the buffer."""
        self._pointer += 4
        handle = self._payload[self._pointer - 4 : self._pointer]
        return struct.unpack(self._byteorder + "I", handle)[0]

    def decode_64bit_uint(self):
        """Decodes a 64-bit unsigned int from the buffer."""
        self._pointer += 8
        handle = self._payload[self._pointer - 8 : self._pointer]
        return struct.unpack(self._byteorder + "Q", handle)[0]

    def decode_string(self, size=1) -> str:
        """Decodes a string from the buffer."""
        if self.remaining_bytes < size:
            raise struct.error(
                f"unpack requires a buffer of {size - self.remaining_bytes} bytes, {self.remaining_bytes} bytes remain"
            )
        self._pointer += size
        return self._payload[self._pointer - size : self._pointer].decode("latin1")

    @property
    def decoding_complete(self) -> bool:
        """Returns whether the payload has been completely decoded."""
        return self._pointer == len(self._payload)

    @property
    def payload_size(self) -> int:
        """Return the number of bytes the payload consists of."""
        return len(self._payload)

    @property
    def decoded_bytes(self) -> int:
        """Return the number of bytes of the payload that have been decoded."""
        return self._pointer

    @property
    def remaining_bytes(self) -> int:
        """Return the number of bytes of the payload that have been decoded."""
        return self.payload_size - self._pointer

    @property
    def remaining_payload(self) -> bytes:
        """Return the unprocessed / remaining tail of the payload."""
        return self._payload[self._pointer :]

decoded_bytes property

Return the number of bytes of the payload that have been decoded.

decoding_complete property

Returns whether the payload has been completely decoded.

payload_size property

Return the number of bytes the payload consists of.

remaining_bytes property

Return the number of bytes of the payload that have been decoded.

remaining_payload property

Return the unprocessed / remaining tail of the payload.

decode_16bit_uint()

Decodes a 16-bit unsigned int from the buffer.

Source code in givenergy_modbus/codec.py
28
29
30
31
32
def decode_16bit_uint(self):
    """Decodes a 16-bit unsigned int from the buffer."""
    self._pointer += 2
    handle = self._payload[self._pointer - 2 : self._pointer]
    return struct.unpack(self._byteorder + "H", handle)[0]

decode_32bit_uint()

Decodes a 32-bit unsigned int from the buffer.

Source code in givenergy_modbus/codec.py
34
35
36
37
38
def decode_32bit_uint(self):
    """Decodes a 32-bit unsigned int from the buffer."""
    self._pointer += 4
    handle = self._payload[self._pointer - 4 : self._pointer]
    return struct.unpack(self._byteorder + "I", handle)[0]

decode_64bit_uint()

Decodes a 64-bit unsigned int from the buffer.

Source code in givenergy_modbus/codec.py
40
41
42
43
44
def decode_64bit_uint(self):
    """Decodes a 64-bit unsigned int from the buffer."""
    self._pointer += 8
    handle = self._payload[self._pointer - 8 : self._pointer]
    return struct.unpack(self._byteorder + "Q", handle)[0]

decode_8bit_uint()

Decodes an 8-bit unsigned int from the buffer.

Source code in givenergy_modbus/codec.py
22
23
24
25
26
def decode_8bit_uint(self):
    """Decodes an 8-bit unsigned int from the buffer."""
    self._pointer += 1
    handle = self._payload[self._pointer - 1 : self._pointer]
    return struct.unpack(self._byteorder + "B", handle)[0]

decode_string(size=1)

Decodes a string from the buffer.

Source code in givenergy_modbus/codec.py
46
47
48
49
50
51
52
53
def decode_string(self, size=1) -> str:
    """Decodes a string from the buffer."""
    if self.remaining_bytes < size:
        raise struct.error(
            f"unpack requires a buffer of {size - self.remaining_bytes} bytes, {self.remaining_bytes} bytes remain"
        )
    self._pointer += size
    return self._payload[self._pointer - size : self._pointer].decode("latin1")

PayloadEncoder

Encode sequential typed fields into a raw binary payload.

Source code in givenergy_modbus/codec.py
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
class PayloadEncoder:
    """Encode sequential typed fields into a raw binary payload."""

    _byteorder = ">"  # big-endian
    _payload: bytes

    def __init__(self):
        self.reset()

    def reset(self):
        """Reset the payload buffer."""
        self._payload = b""

    @property
    def payload(self) -> bytes:
        """Return the payload buffer."""
        return self._payload

    @property
    def crc(self) -> int:
        """Calculate a Modbus-compatible CRC based on the buffer contents."""
        return CrcModbus().process(self.payload).final()

    def add_8bit_uint(self, value: int):
        """Adds an 8-bit unsigned int to the buffer."""
        fstring = self._byteorder + "B"
        self._payload += struct.pack(fstring, value)

    def add_16bit_uint(self, value):
        """Adds a 16-bit unsigned int to the buffer."""
        fstring = self._byteorder + "H"
        self._payload += struct.pack(fstring, value)

    def add_32bit_uint(self, value):
        """Adds a 32-bit unsigned int to the buffer."""
        fstring = self._byteorder + "I"
        self._payload += struct.pack(fstring, value)

    def add_64bit_uint(self, value):
        """Adds a 64-bit unsigned int to the buffer."""
        fstring = self._byteorder + "Q"
        self._payload += struct.pack(fstring, value)

    def add_string(self, value: str, length: int):
        """Adds a string to the buffer."""
        fstring = self._byteorder + str(length) + "s"
        pstring = f"{value[-length:]:*>{length}}".encode()
        self._payload += struct.pack(fstring, pstring)

crc property

Calculate a Modbus-compatible CRC based on the buffer contents.

payload property

Return the payload buffer.

add_16bit_uint(value)

Adds a 16-bit unsigned int to the buffer.

Source code in givenergy_modbus/codec.py
109
110
111
112
def add_16bit_uint(self, value):
    """Adds a 16-bit unsigned int to the buffer."""
    fstring = self._byteorder + "H"
    self._payload += struct.pack(fstring, value)

add_32bit_uint(value)

Adds a 32-bit unsigned int to the buffer.

Source code in givenergy_modbus/codec.py
114
115
116
117
def add_32bit_uint(self, value):
    """Adds a 32-bit unsigned int to the buffer."""
    fstring = self._byteorder + "I"
    self._payload += struct.pack(fstring, value)

add_64bit_uint(value)

Adds a 64-bit unsigned int to the buffer.

Source code in givenergy_modbus/codec.py
119
120
121
122
def add_64bit_uint(self, value):
    """Adds a 64-bit unsigned int to the buffer."""
    fstring = self._byteorder + "Q"
    self._payload += struct.pack(fstring, value)

add_8bit_uint(value)

Adds an 8-bit unsigned int to the buffer.

Source code in givenergy_modbus/codec.py
104
105
106
107
def add_8bit_uint(self, value: int):
    """Adds an 8-bit unsigned int to the buffer."""
    fstring = self._byteorder + "B"
    self._payload += struct.pack(fstring, value)

add_string(value, length)

Adds a string to the buffer.

Source code in givenergy_modbus/codec.py
124
125
126
127
128
def add_string(self, value: str, length: int):
    """Adds a string to the buffer."""
    fstring = self._byteorder + str(length) + "s"
    pstring = f"{value[-length:]:*>{length}}".encode()
    self._payload += struct.pack(fstring, pstring)

reset()

Reset the payload buffer.

Source code in givenergy_modbus/codec.py
90
91
92
def reset(self):
    """Reset the payload buffer."""
    self._payload = b""

Exceptions

CommunicationError

Bases: ExceptionBase

Exception to indicate a communication error.

Source code in givenergy_modbus/exceptions.py
37
38
class CommunicationError(ExceptionBase):
    """Exception to indicate a communication error."""

ConnectionLost

Bases: CommunicationError, TimeoutError

The TCP connection dropped mid-operation (peer disconnect, half-open stall).

Deliberately inherits BOTH CommunicationError and TimeoutError (#356): the dual base is a compatibility contract, not an accident. Consumers that catch bare TimeoutError (the historical error type for a dead connection) see no behaviour change; consumers may catch ConnectionLost explicitly — ordered before TimeoutError — to opt into immediate-reconnect policy. Do not "simplify" to a single base.

Source code in givenergy_modbus/exceptions.py
41
42
43
44
45
46
47
48
49
50
class ConnectionLost(CommunicationError, TimeoutError):
    """The TCP connection dropped mid-operation (peer disconnect, half-open stall).

    Deliberately inherits BOTH CommunicationError and TimeoutError (#356): the
    dual base is a compatibility contract, not an accident. Consumers that catch
    bare ``TimeoutError`` (the historical error type for a dead connection) see
    no behaviour change; consumers may catch ``ConnectionLost`` explicitly —
    ordered before ``TimeoutError`` — to opt into immediate-reconnect policy.
    Do not "simplify" to a single base.
    """

ExceptionBase

Bases: Exception

Base exception.

Source code in givenergy_modbus/exceptions.py
 9
10
11
12
13
14
15
16
class ExceptionBase(Exception):
    """Base exception."""

    message: str

    def __init__(self, message: str) -> None:
        super().__init__(message)
        self.message = message

InvalidFrame

Bases: ExceptionBase

Thrown during framing when a message cannot be extracted from a frame buffer.

Source code in givenergy_modbus/exceptions.py
27
28
29
30
31
32
33
34
class InvalidFrame(ExceptionBase):
    """Thrown during framing when a message cannot be extracted from a frame buffer."""

    frame: bytes

    def __init__(self, message: str, frame: bytes) -> None:
        super().__init__(message=message)
        self.frame = frame

InvalidPduState

Bases: ExceptionBase

Thrown during PDU self-validation.

Source code in givenergy_modbus/exceptions.py
19
20
21
22
23
24
class InvalidPduState(ExceptionBase):
    """Thrown during PDU self-validation."""

    def __init__(self, message: str, pdu) -> None:
        super().__init__(message=message)
        self.pdu = pdu

PlantNotDetected

Bases: CommunicationError

Raised when a capability-aware poll is attempted before detect() has run.

load_config() / refresh() route by plant.capabilities (device kind, inverter address, slot layout). With no capabilities there is no safe default — guessing an inverter address (historically 0x32) silently times out on models that answer elsewhere (e.g. an All-in-One at 0x11). Rather than guess, the poll refuses: call detect() once first, or restore a persisted PlantCapabilities onto client.plant.capabilities before polling.

Source code in givenergy_modbus/exceptions.py
53
54
55
56
57
58
59
60
61
62
class PlantNotDetected(CommunicationError):
    """Raised when a capability-aware poll is attempted before ``detect()`` has run.

    ``load_config()`` / ``refresh()`` route by ``plant.capabilities`` (device kind,
    inverter address, slot layout). With no capabilities there is no safe default —
    guessing an inverter address (historically ``0x32``) silently times out on models
    that answer elsewhere (e.g. an All-in-One at ``0x11``). Rather than guess, the poll
    refuses: call ``detect()`` once first, or restore a persisted ``PlantCapabilities``
    onto ``client.plant.capabilities`` before polling.
    """

PlantTopologyMismatch

Bases: CommunicationError

Raised when detect(prior=...) finds the plant doesn't match the supplied prior.

Carries both prior (what the caller asserted) and actual (a PlantCapabilities reflecting what confirmed on this run). On raise, the Client's plant.capabilities is left as None — callers that wish to accept the new topology must explicitly assign client.plant.capabilities = exc.actual.

Caller policy decides whether to retry (e.g. with longer timeouts), fall back to detect() without prior, or surface the change to the user.

Source code in givenergy_modbus/exceptions.py
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
class PlantTopologyMismatch(CommunicationError):
    """Raised when detect(prior=...) finds the plant doesn't match the supplied prior.

    Carries both `prior` (what the caller asserted) and `actual` (a PlantCapabilities
    reflecting what confirmed on this run). On raise, the Client's plant.capabilities
    is left as None — callers that wish to accept the new topology must explicitly
    assign `client.plant.capabilities = exc.actual`.

    Caller policy decides whether to retry (e.g. with longer timeouts), fall back to
    detect() without prior, or surface the change to the user.
    """

    def __init__(
        self,
        message: str,
        prior,
        actual,
    ) -> None:
        super().__init__(message=message)
        self.prior = prior
        self.actual = actual

ReadFailure

Bases: NamedTuple

Identifies a single register read that failed (after retries) during a poll.

Structured so a consumer can reason about which device and bank dropped (e.g. "battery 0x34 is offline") without parsing log lines.

Source code in givenergy_modbus/exceptions.py
88
89
90
91
92
93
94
95
96
97
98
class ReadFailure(NamedTuple):
    """Identifies a single register read that failed (after retries) during a poll.

    Structured so a consumer can reason about *which* device and bank dropped
    (e.g. "battery 0x34 is offline") without parsing log lines.
    """

    device_address: int
    request_type: str
    base_register: int
    register_count: int

RefreshError

Bases: CommunicationError

Base for a refresh()/load_config() that did not fully succeed.

Carries the structured set of reads that failed (failures) plus the raw underlying exceptions grouped as an ExceptionGroup (cause) for tracebacks / drill-down.

Source code in givenergy_modbus/exceptions.py
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
class RefreshError(CommunicationError):
    """Base for a refresh()/load_config() that did not fully succeed.

    Carries the structured set of reads that failed (``failures``) plus the raw
    underlying exceptions grouped as an ``ExceptionGroup`` (``cause``) for
    tracebacks / drill-down.
    """

    failures: list[ReadFailure]
    cause: ExceptionGroup

    def __init__(self, message: str, failures: list[ReadFailure], cause: ExceptionGroup) -> None:
        super().__init__(message=message)
        self.failures = failures
        self.cause = cause

RefreshFailed

Bases: RefreshError

Every register read in the poll failed — the link is effectively dead.

No usable data came back, so (unlike RefreshPartiallySucceeded) there is no partial plant to hand over; callers should treat the device as unavailable.

Source code in givenergy_modbus/exceptions.py
136
137
138
139
140
141
142
class RefreshFailed(RefreshError):
    """Every register read in the poll failed — the link is effectively dead.

    No usable data came back, so (unlike ``RefreshPartiallySucceeded``) there is
    no partial plant to hand over; callers should treat the device as
    unavailable.
    """

RefreshPartiallySucceeded

Bases: RefreshError

Some — but not all — register reads in a poll failed.

The data that was collected is attached as plant. This exception is the consumer's one opportunity to do something useful with that partial data — cache it, surface it, count the gap — before deciding how to treat the missing reads. Catching it and carrying on (even ignoring it) is a legitimate choice; the point is that it's the consumer's choice, made here, rather than something the library silently decided for them.

Source code in givenergy_modbus/exceptions.py
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
class RefreshPartiallySucceeded(RefreshError):
    """Some — but not all — register reads in a poll failed.

    The data that *was* collected is attached as ``plant``. This exception is
    the consumer's one opportunity to do something useful with that partial
    data — cache it, surface it, count the gap — before deciding how to treat
    the missing reads. Catching it and carrying on (even ignoring it) is a
    legitimate choice; the point is that it's the *consumer's* choice, made
    here, rather than something the library silently decided for them.
    """

    plant: Plant

    def __init__(self, message: str, plant: Plant, failures: list[ReadFailure], cause: ExceptionGroup) -> None:
        super().__init__(message=message, failures=failures, cause=cause)
        self.plant = plant