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 via self.connected flipping to False; the consumer task logs CRITICAL when this happens. 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
 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
 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
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 via ``self.connected`` flipping to ``False``;
      the consumer task logs CRITICAL when this happens. ``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,
    ) -> 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()
        self.tx_queue = Queue(maxsize=20)
        self.expected_responses = {}
        self._shutting_down = False
        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
        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),
        # }

    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 _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_cache.get(IR(64)) or 0
                    caps.bcu_stacks.append((offset, actual_modules))
            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,
        ):
            return
        bms_cache: RegisterCache = self.plant.register_caches.get(0xA0, RegisterCache())
        num_bcus = bms_cache.get(IR(61)) or 0
        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_cache.get(IR(64)) or 0
                caps.bcu_stacks.append((i, num_modules))

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

    async def _detect_aio_battery_modules(
        self,
        caps: PlantCapabilities,
        prior: PlantCapabilities | None,
        probe_timeout: float,
        probe_retries: int,
    ) -> None:
        """Populate caps.aio_battery_module_addresses for an All-in-One (#192).

        Hinted mode (prior is not None): probe only the previously-seen module addresses
        so detect() stays a confirmation pass rather than a fresh sweep — consistent with
        the meter and battery hinted contracts.

        Cold mode: derive candidates from the BCU-reported module count (IR 64), bounded
        to _AIO_MAX_MODULES (0x50–0x53) to guard against stale or corrupt register values.

        Single-BCU AIO only — multi-BCU module addressing is a future extension.
        """
        if prior is not None:
            candidates: list[int] = list(prior.aio_battery_module_addresses)
        else:
            if not caps.bcu_stacks:
                return
            _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,
                )
                num_modules = self._AIO_MAX_MODULES
            candidates = [0x50 + i for i in range(num_modules)]
        for addr in candidates:
            if not await self._probe(
                ReadInputRegistersRequest(base_register=60, register_count=60, device_address=addr),
                timeout=probe_timeout,
                retries=probe_retries,
            ):
                continue
            cache = self.plant.register_caches.get(addr)
            try:
                if cache is None or not AioBatteryModule.from_register_cache(cache, addr).is_valid():
                    _logger.debug("detect: AIO module probe at 0x%02x responded but is_valid()=False — skipping", addr)
                    continue
            except Exception:
                _logger.debug("detect: AIO module probe at 0x%02x failed to decode — skipping", addr, exc_info=True)
                continue
            caps.aio_battery_module_addresses.append(addr)

    async def _detect_lv_bcu(
        self,
        caps: PlantCapabilities,
        prior: PlantCapabilities | None,
        probe_timeout: float,
        probe_retries: int,
    ) -> None:
        """Probe the LV BCU page and set caps.lv_bcu_address when present (#241).

        The stack-level BMS block (doc §4.4.1.1) answers at 0x31 IR(60-63) on LV
        systems, firmware-gated: absent units still respond, just with an all-zero
        block, so is_valid() (any-word-non-zero) is the presence test and the probe
        costs nothing on healthy systems. Hinted mode follows the meter convention:
        only re-check an address the prior captured — prior None-ness is trusted, so
        a firmware upgrade that adds the block needs a cold detect to notice.
        """
        bcu_addr = prior.lv_bcu_address if prior is not None else LV_BCU_ADDRESS
        if bcu_addr is None:
            return
        if not await self._probe(
            ReadInputRegistersRequest(base_register=60, register_count=60, device_address=bcu_addr),
            timeout=probe_timeout,
            retries=probe_retries,
        ):
            return
        bcu_cache = self.plant.register_caches.get(bcu_addr)
        if not bcu_cache:
            return
        try:
            if LvBcu.from_register_cache(bcu_cache).is_valid():
                caps.lv_bcu_address = bcu_addr
        except (KeyError, ValueError):
            _logger.debug("detect: LV BCU probe at 0x%02x failed to decode — skipping", bcu_addr, exc_info=True)

    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 _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
        caps = PlantCapabilities(device_type=resolve_model(raw_dtc, arm_fw))
        _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:
            await self._detect_aio_battery_modules(caps, prior, probe_timeout, probe_retries)
            _logger.info(
                "detect: aio_battery_modules=[%s]",
                ", ".join(f"0x{a:02x}" for a in caps.aio_battery_module_addresses),
            )

        # 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, matching the
        # convention used for Battery and BCU validation. Per-slot (not break-on-fail) because
        # meters can be non-contiguous (e.g. ports 1 and 3 populated, port 2 empty). See #95.
        meter_candidates = prior.meter_addresses if prior is not None else range(0x01, 0x09)
        for meter_addr in meter_candidates:
            if not await self._probe(
                ReadInputRegistersRequest(base_register=60, register_count=30, device_address=meter_addr),
                timeout=probe_timeout,
                retries=probe_retries,
            ):
                continue
            meter_cache = self.plant.register_caches.get(meter_addr)
            if meter_cache is None or not Meter.from_register_cache(meter_cache).is_valid():
                _logger.debug(
                    "detect: meter probe responded at 0x%02x but is_valid()=False — skipping",
                    meter_addr,
                )
                continue
            caps.meter_addresses.append(meter_addr)
        _logger.info(
            "detect: meter_addresses=[%s]",
            ", ".join(f"0x{a:02x}" for a in caps.meter_addresses),
        )

        # Step 4 — LV battery detection. Battery pack #1 is at 0x32, additional batteries at
        # 0x33–0x37 (the inverter itself lives at 0x11, not 0x32 — issues #119/#189). All
        # slots are validated via Battery.is_valid(). Skipped for HV systems (handled at step 2)
        # and EMS plant controllers (don't expose IR at the inverter address — see #86).
        # Per-slot (not break-on-fail) for the same reasons as the meter sweep above: battery
        # addresses can be non-contiguous (DIP-switch misconfiguration) and a transient BMS
        # timeout on pack N must not silently drop pack N+1 onward. Cold detect is affordable
        # since the probe budget only applies on cold sweeps; warm/hinted starts skip this.
        if not caps.is_hv and not caps.is_ems:
            await self.send_request_and_await_response(
                ReadInputRegistersRequest(base_register=60, register_count=60, device_address=0x32),
                timeout=timeout,
                retries=retries,
            )
            batt_candidates = prior.lv_battery_addresses if prior is not None else range(0x32, 0x38)
            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,
                    ):
                        continue
                    if not self.plant.register_caches.get(batt_addr):
                        continue
                try:
                    if not Battery.from_register_cache(self.plant.register_caches[batt_addr]).is_valid():
                        _logger.debug(
                            "detect: battery probe responded at 0x%02x but is_valid()=False — skipping",
                            batt_addr,
                        )
                        continue
                except (KeyError, ValueError):
                    continue
                caps.lv_battery_addresses.append(batt_addr)
            _logger.info(
                "detect: lv_battery_addresses=[%s]",
                ", ".join(f"0x{a:02x}" for a in caps.lv_battery_addresses),
            )

            # Step 4b — LV BCU page probe (#241).
            await self._detect_lv_bcu(caps, prior, probe_timeout, probe_retries)
            _logger.info(
                "detect: lv_bcu_address=%s",
                f"0x{caps.lv_bcu_address:02x}" if caps.lv_bcu_address is not None else "None",
            )

        # 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)

        if prior is not None and prior != caps:
            self.plant.capabilities = None
            raise PlantTopologyMismatch(
                f"detect: plant topology does not match prior — prior={prior!r}, actual={caps!r}",
                prior=prior,
                actual=caps,
            )

        self.plant.capabilities = caps
        return 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 += [
                ReadHoldingRegistersRequest(base_register=1000, register_count=60, device_address=inverter),
                ReadHoldingRegistersRequest(base_register=1060, register_count=60, device_address=inverter),
                ReadHoldingRegistersRequest(base_register=1120, register_count=5, device_address=inverter),
            ]
        if caps.has_extended_slots:
            reqs.append(ReadHoldingRegistersRequest(base_register=240, register_count=60, device_address=inverter))
        if caps.has_smart_load_block:
            # HR(540-599) — Smart Load scheduling slots 1–10 (HR554-573). Gated because
            # the block was added from the app's Direct Control catalogue (writable
            # surface only — never confirmed to answer a live read) and HYBRID_GEN1 times
            # out on it (#179). The gate set is currently empty, so this is off for every
            # model pending hardware confirmation; the smart_load_slot_* decode Defs and
            # set_smart_load_slot_* write helpers are unaffected. Unmodelled registers in
            # 540-553 and 574-599 are silently ignored by Plant.update(). (#48, #179)
            reqs.append(ReadHoldingRegistersRequest(base_register=540, register_count=60, device_address=inverter))
        if caps.has_ac_config_block:
            # HR(300-359) — AC-output config: export_priority (HR311), battery_*_limit_ac
            # (HR313/314), enable_eps (HR317), pause mode/slot (HR318-320). Present on
            # AC-coupled inverters AND the All-in-One; DC-coupled/hybrid models time out on
            # this block (#162). Confirmed present on Model.AC (hass#52 portal writes) and
            # the AIO (live poll populated these fields, #105).
            reqs.append(ReadHoldingRegistersRequest(base_register=300, register_count=60, device_address=inverter))
        if caps.is_ems:
            reqs.append(ReadHoldingRegistersRequest(base_register=2040, register_count=36, device_address=inverter))
        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,
    ) -> 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.

        ``ir0_max_age`` (seconds) opts in to skip-if-fresh for the IR(0,60) live block
        (#196): GivEnergy dongles fan out the responses to whoever is polling them (the
        cloud, the app, another client), so the network consumer often already has a
        recent IR(0,60) in cache without us asking. When set, if IR(0,60) was committed
        within ``ir0_max_age`` seconds it is not re-solicited this cycle, sparing the
        (often flaky) dongle a request. Defaults to ``None`` — always solicit, the
        historic behaviour. Scoped to IR(0,60) only for now; broaden once soak-tested.
        Note the fan-out only exists while something else is polling the unit; on a
        cloud-disconnected dongle the block ages out and we solicit it as normal.
        """
        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."
            )
        inverter = caps.inverter_address
        reqs: list[TransparentRequest] = []
        # EMS plant controllers don't expose IR(0,60) or IR(180,60) — see load_config() and #86.
        if not caps.is_ems:
            ir0_age = self.plant.block_age(inverter, "IR", 0, 60) if ir0_max_age is not None else None
            if ir0_max_age is not None and ir0_age is not None and ir0_age <= ir0_max_age:
                _logger.debug(
                    "Skipping IR(0,60) solicit for device 0x%02x — fan-out kept it fresh (%.1fs <= %.1fs)",
                    inverter,
                    ir0_age,
                    ir0_max_age,
                )
            else:
                reqs.append(ReadInputRegistersRequest(base_register=0, register_count=60, device_address=inverter))
            reqs.append(ReadInputRegistersRequest(base_register=180, register_count=60, device_address=inverter))
        if caps.is_three_phase:
            for base in range(1000, 1414, 60):
                reqs.append(
                    ReadInputRegistersRequest(
                        base_register=base,
                        register_count=min(60, 1414 - base),
                        device_address=inverter,
                    )
                )
        if caps.is_ems:
            reqs.append(ReadInputRegistersRequest(base_register=2040, register_count=55, device_address=inverter))
        if caps.is_gateway:
            for base in range(1600, 1860, 60):
                reqs.append(
                    ReadInputRegistersRequest(
                        base_register=base,
                        register_count=min(60, 1860 - base),
                        device_address=inverter,
                    )
                )
        for addr in caps.lv_battery_addresses:
            reqs.append(ReadInputRegistersRequest(base_register=60, register_count=60, device_address=addr))
        if caps.lv_bcu_address is not None:
            # LV BCU stack-level page (#241) — count 60 matches the field-validated probe shape.
            reqs.append(
                ReadInputRegistersRequest(base_register=60, register_count=60, device_address=caps.lv_bcu_address)
            )
        for addr in caps.meter_addresses:
            reqs.append(ReadInputRegistersRequest(base_register=60, register_count=30, device_address=addr))
        for offset, _ in caps.bcu_stacks:
            reqs.append(ReadInputRegistersRequest(base_register=60, register_count=60, device_address=0x70 + offset))
        # AIO per-module battery caches (#192) — each module answers at its own address.
        for addr in caps.aio_battery_module_addresses:
            reqs.append(ReadInputRegistersRequest(base_register=60, register_count=60, device_address=addr))
        await self._execute_reads(reqs, 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,
                )

    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
        if caps is not None and caps.is_ems:
            safe = _EmsCommands.WRITE_SAFE_REGISTERS
        elif caps is not None and caps.is_three_phase:
            safe = _ThreePhaseCommands.WRITE_SAFE_REGISTERS
        else:
            safe = _InverterCommands.WRITE_SAFE_REGISTERS
        model_label = caps.device_type.name if caps is not None else "undetected"
        for req in requests:
            if isinstance(req, WriteHoldingRegisterRequest) and 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)

    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

    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
            _logger.critical("network_consumer reader at EOF, cannot continue")

    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
            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 self.writer.drain()
            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.critical("network_producer writer is closing, cannot continue")

    # 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(
        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``.
        """
        # 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
            # 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 TimeoutError as exc:
                # Producer is genuinely stuck. Drop the orphaned future so a late send can't
                # resolve a stale request, and surface a clear error.
                _discard(response_future)
                raise TimeoutError("Producer task is stuck — frame not sent") from exc
            try:
                await asyncio.wait_for(response_future, timeout=timeout)
            except TimeoutError:
                tries += 1
                _logger.debug(
                    f"Timeout awaiting {expected_response} (future: {response_future}), "
                    f"attempting retry {tries} of {retries}"
                )
                if tries <= retries and 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 and 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
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
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
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
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
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
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
    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
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
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
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
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,
    )

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
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
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 += [
            ReadHoldingRegistersRequest(base_register=1000, register_count=60, device_address=inverter),
            ReadHoldingRegistersRequest(base_register=1060, register_count=60, device_address=inverter),
            ReadHoldingRegistersRequest(base_register=1120, register_count=5, device_address=inverter),
        ]
    if caps.has_extended_slots:
        reqs.append(ReadHoldingRegistersRequest(base_register=240, register_count=60, device_address=inverter))
    if caps.has_smart_load_block:
        # HR(540-599) — Smart Load scheduling slots 1–10 (HR554-573). Gated because
        # the block was added from the app's Direct Control catalogue (writable
        # surface only — never confirmed to answer a live read) and HYBRID_GEN1 times
        # out on it (#179). The gate set is currently empty, so this is off for every
        # model pending hardware confirmation; the smart_load_slot_* decode Defs and
        # set_smart_load_slot_* write helpers are unaffected. Unmodelled registers in
        # 540-553 and 574-599 are silently ignored by Plant.update(). (#48, #179)
        reqs.append(ReadHoldingRegistersRequest(base_register=540, register_count=60, device_address=inverter))
    if caps.has_ac_config_block:
        # HR(300-359) — AC-output config: export_priority (HR311), battery_*_limit_ac
        # (HR313/314), enable_eps (HR317), pause mode/slot (HR318-320). Present on
        # AC-coupled inverters AND the All-in-One; DC-coupled/hybrid models time out on
        # this block (#162). Confirmed present on Model.AC (hass#52 portal writes) and
        # the AIO (live poll populated these fields, #105).
        reqs.append(ReadHoldingRegistersRequest(base_register=300, register_count=60, device_address=inverter))
    if caps.is_ems:
        reqs.append(ReadHoldingRegistersRequest(base_register=2040, register_count=36, device_address=inverter))
    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
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
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
    if caps is not None and caps.is_ems:
        safe = _EmsCommands.WRITE_SAFE_REGISTERS
    elif caps is not None and caps.is_three_phase:
        safe = _ThreePhaseCommands.WRITE_SAFE_REGISTERS
    else:
        safe = _InverterCommands.WRITE_SAFE_REGISTERS
    model_label = caps.device_type.name if caps is not None else "undetected"
    for req in requests:
        if isinstance(req, WriteHoldingRegisterRequest) and 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)

refresh(timeout=2.0, retries=1, retry_delay=0.5, ir0_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.

ir0_max_age (seconds) opts in to skip-if-fresh for the IR(0,60) live block (#196): GivEnergy dongles fan out the responses to whoever is polling them (the cloud, the app, another client), so the network consumer often already has a recent IR(0,60) in cache without us asking. When set, if IR(0,60) was committed within ir0_max_age seconds it is not re-solicited this cycle, sparing the (often flaky) dongle a request. Defaults to None — always solicit, the historic behaviour. Scoped to IR(0,60) only for now; broaden once soak-tested. Note the fan-out only exists while something else is polling the unit; on a cloud-disconnected dongle the block ages out and we solicit it as normal.

Source code in givenergy_modbus/client/client.py
 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
async def refresh(
    self,
    timeout: float = 2.0,
    retries: int = 1,
    retry_delay: float = 0.5,
    ir0_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.

    ``ir0_max_age`` (seconds) opts in to skip-if-fresh for the IR(0,60) live block
    (#196): GivEnergy dongles fan out the responses to whoever is polling them (the
    cloud, the app, another client), so the network consumer often already has a
    recent IR(0,60) in cache without us asking. When set, if IR(0,60) was committed
    within ``ir0_max_age`` seconds it is not re-solicited this cycle, sparing the
    (often flaky) dongle a request. Defaults to ``None`` — always solicit, the
    historic behaviour. Scoped to IR(0,60) only for now; broaden once soak-tested.
    Note the fan-out only exists while something else is polling the unit; on a
    cloud-disconnected dongle the block ages out and we solicit it as normal.
    """
    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."
        )
    inverter = caps.inverter_address
    reqs: list[TransparentRequest] = []
    # EMS plant controllers don't expose IR(0,60) or IR(180,60) — see load_config() and #86.
    if not caps.is_ems:
        ir0_age = self.plant.block_age(inverter, "IR", 0, 60) if ir0_max_age is not None else None
        if ir0_max_age is not None and ir0_age is not None and ir0_age <= ir0_max_age:
            _logger.debug(
                "Skipping IR(0,60) solicit for device 0x%02x — fan-out kept it fresh (%.1fs <= %.1fs)",
                inverter,
                ir0_age,
                ir0_max_age,
            )
        else:
            reqs.append(ReadInputRegistersRequest(base_register=0, register_count=60, device_address=inverter))
        reqs.append(ReadInputRegistersRequest(base_register=180, register_count=60, device_address=inverter))
    if caps.is_three_phase:
        for base in range(1000, 1414, 60):
            reqs.append(
                ReadInputRegistersRequest(
                    base_register=base,
                    register_count=min(60, 1414 - base),
                    device_address=inverter,
                )
            )
    if caps.is_ems:
        reqs.append(ReadInputRegistersRequest(base_register=2040, register_count=55, device_address=inverter))
    if caps.is_gateway:
        for base in range(1600, 1860, 60):
            reqs.append(
                ReadInputRegistersRequest(
                    base_register=base,
                    register_count=min(60, 1860 - base),
                    device_address=inverter,
                )
            )
    for addr in caps.lv_battery_addresses:
        reqs.append(ReadInputRegistersRequest(base_register=60, register_count=60, device_address=addr))
    if caps.lv_bcu_address is not None:
        # LV BCU stack-level page (#241) — count 60 matches the field-validated probe shape.
        reqs.append(
            ReadInputRegistersRequest(base_register=60, register_count=60, device_address=caps.lv_bcu_address)
        )
    for addr in caps.meter_addresses:
        reqs.append(ReadInputRegistersRequest(base_register=60, register_count=30, device_address=addr))
    for offset, _ in caps.bcu_stacks:
        reqs.append(ReadInputRegistersRequest(base_register=60, register_count=60, device_address=0x70 + offset))
    # AIO per-module battery caches (#192) — each module answers at its own address.
    for addr in caps.aio_battery_module_addresses:
        reqs.append(ReadInputRegistersRequest(base_register=60, register_count=60, device_address=addr))
    await self._execute_reads(reqs, 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
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
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
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
async def send_request_and_await_response(
    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``.
    """
    # 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
        # 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 TimeoutError as exc:
            # Producer is genuinely stuck. Drop the orphaned future so a late send can't
            # resolve a stale request, and surface a clear error.
            _discard(response_future)
            raise TimeoutError("Producer task is stuck — frame not sent") from exc
        try:
            await asyncio.wait_for(response_future, timeout=timeout)
        except TimeoutError:
            tries += 1
            _logger.debug(
                f"Timeout awaiting {expected_response} (future: {response_future}), "
                f"attempting retry {tries} of {retries}"
            )
            if tries <= retries and 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 and 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
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
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
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
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.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)
        if hasattr(pdu, "data_adapter_serial_number"):
            pdu.data_adapter_serial_number = Converter.redact_serial(pdu.data_adapter_serial_number) or ""
        if hasattr(pdu, "inverter_serial_number"):
            pdu.inverter_serial_number = Converter.redact_serial(pdu.inverter_serial_number) or ""

        # 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 _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) or ""
                # 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
143
144
145
146
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
148
149
150
151
152
153
154
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

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

disable_charge()

Prevent the battery from charging at all.

Source code in givenergy_modbus/client/commands.py
220
221
222
223
@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
153
154
155
156
157
158
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
280
281
282
283
284
285
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
232
233
234
235
@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_charge()

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

Source code in givenergy_modbus/client/commands.py
214
215
216
217
@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
226
227
228
229
@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
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
@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
656
657
658
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
679
680
681
682
@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
691
692
693
694
@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
668
669
670
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
703
704
705
706
@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
715
716
717
718
@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)

set_ac_charge(enabled)

Enable AC charging on three-phase inverters.

Source code in givenergy_modbus/client/commands.py
457
458
459
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.

Source code in givenergy_modbus/client/commands.py
358
359
360
361
362
363
def set_active_power_rate(target: int) -> list[TransparentRequest]:
    """Set the inverter's active power output as a percentage of its rated capacity."""
    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_battery_charge_limit(val)

Set the battery charge power limit as a percentage of rated charge power (0–50).

Source code in givenergy_modbus/client/commands.py
329
330
331
332
333
334
def set_battery_charge_limit(val: int) -> list[TransparentRequest]:
    """Set the battery charge power limit as a percentage of rated charge power (0–50)."""
    val = _as_int(val, "val")
    if not 0 <= val <= 50:
        raise ValueError(f"Specified Charge Limit ({val}%) is not in [0-50]%")
    return [WriteHoldingRegisterRequest(RegisterMap.BATTERY_CHARGE_LIMIT, val)]

set_battery_charge_limit_ac(val)

Set the battery AC charge power limit as a percentage.

Source code in givenergy_modbus/client/commands.py
396
397
398
399
400
401
def set_battery_charge_limit_ac(val: int) -> list[TransparentRequest]:
    """Set the battery AC charge power limit as a percentage."""
    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 power limit as a percentage of rated discharge power (0–50).

Source code in givenergy_modbus/client/commands.py
337
338
339
340
341
342
def set_battery_discharge_limit(val: int) -> list[TransparentRequest]:
    """Set the battery discharge power limit as a percentage of rated discharge power (0–50)."""
    val = _as_int(val, "val")
    if not 0 <= val <= 50:
        raise ValueError(f"Specified Discharge Limit ({val}%) is not in [0-50]%")
    return [WriteHoldingRegisterRequest(RegisterMap.BATTERY_DISCHARGE_LIMIT, val)]

set_battery_discharge_limit_ac(val)

Set the battery AC discharge power limit as a percentage.

Source code in givenergy_modbus/client/commands.py
404
405
406
407
408
409
def set_battery_discharge_limit_ac(val: int) -> list[TransparentRequest]:
    """Set the battery AC discharge power limit as a percentage."""
    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_pause_mode(val)

Set the battery pause mode.

Source code in givenergy_modbus/client/commands.py
412
413
414
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
345
346
347
348
349
350
351
352
353
354
355
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
267
268
269
270
271
272
273
274
275
276
277
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
254
255
256
257
258
259
260
261
262
263
264
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
288
289
290
291
292
293
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
204
205
206
207
208
209
210
211
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
649
650
651
652
653
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
673
674
675
676
@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
685
686
687
688
@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
631
632
633
634
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
625
626
627
628
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
175
176
177
178
@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
315
316
317
318
@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
161
162
163
164
165
166
167
168
169
170
171
172
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
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
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
181
182
183
184
185
186
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
321
322
323
324
325
326
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
238
239
240
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
243
244
245
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
661
662
663
664
665
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
697
698
699
700
@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
709
710
711
712
@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
643
644
645
646
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
637
638
639
640
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
512
513
514
515
516
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
524
525
526
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
519
520
521
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
546
547
548
549
550
551
552
553
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
529
530
531
532
533
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
541
542
543
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
536
537
538
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
556
557
558
559
560
561
562
563
564
565
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
598
599
600
601
602
603
604
605
606
607
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
568
569
570
571
572
573
574
575
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
583
584
585
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
578
579
580
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
588
589
590
591
592
593
594
595
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
472
473
474
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
189
190
191
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
194
195
196
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
388
389
390
391
392
393
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_rtc(enabled)

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

Source code in givenergy_modbus/client/commands.py
366
367
368
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_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
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
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 GRID_FIRST 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
496
497
498
499
500
501
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
490
491
492
493
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
484
485
486
487
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_force_charge(enabled)

Enable forced battery charging on three-phase inverters.

Source code in givenergy_modbus/client/commands.py
462
463
464
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
467
468
469
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_inverter_reboot()

Restart the inverter.

Source code in givenergy_modbus/client/commands.py
199
200
201
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
737
738
739
740
741
742
743
744
745
746
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
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
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
427
428
429
430
431
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
422
423
424
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
417
418
419
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_shallow_charge(val)

Set the minimum level of charge to maintain.

Source code in givenergy_modbus/client/commands.py
248
249
250
251
@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_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
450
451
452
453
454
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
442
443
444
445
446
447
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
434
435
436
437
438
439
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
721
722
723
724
725
726
727
728
729
730
731
732
733
734
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),
    ]

Model

Plant

Bases: GivEnergyBaseModel

Representation of a complete GivEnergy plant.

Source code in givenergy_modbus/model/plant.py
 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
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)

    # 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, rebuilt with a fresh Plant on reconnect; not serialised.
    _splice_escrow: dict[int, tuple[int, int]] = 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; resets
    # with a fresh Plant on reconnect; not serialised.
    _splice_last_seen: dict[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()}

    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 we fall back to the legacy mapping where
        the inverter was cached at 0x32, and also treat 0x11/0x31 as inverter so
        replay/debug tooling (which feeds PDUs to a bare Plant) keeps validating
        inverter banks at either wire address — including passive captures of
        other consumers still polling the 0x31 facade.
        """
        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 0x32 <= device_address <= 0x37:
                return BatteryRegisterGetter
        else:
            if device_address in (0x11, 0x31, 0x32):
                return SinglePhaseInverterRegisterGetter
            if 0x33 <= 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),
            }
        )

    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):
            _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._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._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
        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 and not self._splice_guard(
            device_address, incoming, register_count, now=received_at
        ):
            return False
        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.
        """
        if register_count < 60:
            return True
        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)
        # 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

        cache = self.register_caches[device_address]
        prev = [0] * 60
        new = [0] * 60
        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 and cached is not None:
                present.add(i)
        if not present:
            return True  # cold start: no last-good to compare against

        # 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:
            self._splice_escrow.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)

        if immut or len(phys) >= 2:
            self._splice_escrow.pop(device_address, None)
            reason = "constant-register change" if immut else ">=2 physics-impossible deltas"
            _logger.warning(
                "Rejected battery bank for device 0x%02x — sub-bus splice (%s); keeping last-good. "
                "Trips: %s. Please report if seen.",
                device_address,
                reason,
                self._format_splice_trips(immut + phys),
            )
            return False

        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)
            # 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).
        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

    @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

    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 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. (#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[0x32])

    @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."""
        if self.capabilities:
            return [
                Battery.from_register_cache(self.register_caches[addr])
                for addr in self.capabilities.lv_battery_addresses
                if addr in self.register_caches
            ]
        return [Battery.from_register_cache(self.register_caches[i + 0x32]) for i in range(self.number_batteries)]

    @property
    def hv_stacks(self) -> list[HvStack]:
        """Return HV battery stacks (BCU + BMUs) for HV systems; empty list for LV systems."""
        if not self.capabilities or not self.capabilities.bcu_stacks:
            return []
        stacks = []
        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 = [Bmu.from_register_cache(cache, i) for i in range(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."""
        if not self.capabilities or not self.capabilities.meter_addresses:
            return {}
        return {
            addr: Meter.from_register_cache(self.register_caches[addr])
            for addr in self.capabilities.meter_addresses
            if addr in self.register_caches
        }

    @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]:
        """Enumerate every device on this plant as typed :class:`PlantDevice` rows.

        Each row carries a generic :class:`DeviceType` discriminator, a serial
        (where the device exposes a valid one), the plant's model where
        meaningful, and the already-decoded typed model in
        :attr:`PlantDevice.device`. Built by composing the existing accessors —
        :attr:`inverters`, :attr:`ems`, :attr:`gateway`, :attr:`meters` — so the
        EMS-rollup-vs-direct decision is honoured once and a controller (EMS or
        gateway) can never appear as an ``INVERTER`` row.

        Batteries and HV stacks are **owned by their inverter** (#106 Phase 2):
        they ride on the ``INVERTER`` row's ``device.batteries`` /
        ``device.hv_stacks`` rather than as top-level rows. Meters are not
        inverter-owned, so they stay flat rows (the Phase 1 meter-identity
        limitation).
        """
        caps = self.capabilities
        model = caps.device_type if caps else None
        # On EMS/Gateway plants the plant model is the controller's model, not an
        # inverter model — don't let directly-decoded inverters inherit it.
        inverter_model = model if caps and not (caps.is_ems or caps.is_gateway) else None
        rows: list[PlantDevice] = []

        # On a gateway plant the singular ``inverter`` decodes the gateway's own
        # cache as a spurious inverter — suppress that row; the GATEWAY row below
        # represents the device instead.
        inverter_emitted = False
        if not (caps and caps.is_gateway):
            for inverter in self.inverters:
                rows.append(
                    PlantDevice(
                        device_type=DeviceType.INVERTER,
                        device=inverter,
                        serial_number=inverter.serial_number or None,
                        # A blinded (EMS-rollup) inverter's own model is unknown;
                        # only a directly-decoded inverter inherits the plant model.
                        model=None if inverter.is_blinded else inverter_model,
                    )
                )
                inverter_emitted = True

        # AIO per-module battery sub-devices (#192) — enumerable BATTERY_MODULE rows,
        # owned by the inverter (also injected on its ``device.battery_modules``), keyed
        # by the module's own HX-prefixed serial. GivTCP surfaces these as separate
        # per-module devices; mirror that so consumers can name one HA device per module.
        for module in self.aio_battery_modules:
            rows.append(
                PlantDevice(
                    device_type=DeviceType.BATTERY_MODULE,
                    device=module,
                    serial_number=_validated_serial(module),
                )
            )

        if (ems := self.ems) is not None:
            rows.append(PlantDevice(device_type=DeviceType.EMS, device=ems, model=model))

        if (gateway := self.gateway) is not None:
            rows.append(PlantDevice(device_type=DeviceType.GATEWAY, device=gateway, model=model))

        # Batteries / HV stacks nest under their inverter via the injected
        # ``inverter.batteries`` / ``.hv_stacks`` (see :attr:`inverters`). The
        # only case with no inverter row to carry them is a gateway plant, where
        # the inverter is suppressed — emit those as flat rows rather than drop
        # them (orphan guard; proper partner-AIO expansion is a later phase).
        if not inverter_emitted:
            for battery in self.batteries:
                rows.append(
                    PlantDevice(
                        device_type=DeviceType.BATTERY,
                        device=battery,
                        serial_number=_validated_serial(battery),
                    )
                )
            for stack in self.hv_stacks:
                rows.append(
                    PlantDevice(
                        device_type=DeviceType.HV_STACK,
                        device=stack,
                        serial_number=_validated_serial(stack.bcu),
                    )
                )

        for meter in self.meters.values():
            rows.append(
                PlantDevice(
                    device_type=DeviceType.METER,
                    device=meter,
                    serial_number=_validated_serial(meter),
                )
            )

        return 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.

devices property

Enumerate every device on this plant as typed :class:PlantDevice rows.

Each row carries a generic :class:DeviceType discriminator, a serial (where the device exposes a valid one), the plant's model where meaningful, and the already-decoded typed model in :attr:PlantDevice.device. Built by composing the existing accessors — :attr:inverters, :attr:ems, :attr:gateway, :attr:meters — so the EMS-rollup-vs-direct decision is honoured once and a controller (EMS or gateway) can never appear as an INVERTER row.

Batteries and HV stacks are owned by their inverter (#106 Phase 2): they ride on the INVERTER row's device.batteries / device.hv_stacks rather than as top-level rows. Meters are not inverter-owned, so they stay flat rows (the Phase 1 meter-identity limitation).

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 + BMUs) for HV systems; empty list for LV systems.

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. (#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.

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.

number_batteries property

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

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
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
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
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
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()

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
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
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()

model_post_init(__context)

Ensure a default register cache is always present.

Source code in givenergy_modbus/model/plant.py
561
562
563
564
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()}

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
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
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_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
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
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
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
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):
        _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._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._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
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
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
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 = 0x32
    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)
    # 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

    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,
        **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
        _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 self.device_type in _HV_MODELS

    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],
            "lv_bcu_address": f"0x{self.lv_bcu_address:02x}" if self.lv_bcu_address is not None else None,
        }

    @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 [])],
            lv_bcu_address=(
                _addr(normalised["lv_bcu_address"]) if normalised.get("lv_bcu_address") is not None else None
            ),
        )

    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)
        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"lv_bcu_address={lv_bcu})"
        )

    @property
    def is_three_phase(self) -> bool:
        """Return True if this system uses three-phase registers (HR/IR 1000-range)."""
        return self.device_type in _THREE_PHASE_MODELS

    @property
    def is_ac_coupled(self) -> bool:
        """Return True if this system is AC-coupled (no integrated DC battery)."""
        return self.device_type in AC_COUPLED_MODELS

    @property
    def has_extended_slots(self) -> bool:
        """Return True if this system supports the extended 10-slot map (HR 240–299)."""
        return self.device_type in _EXTENDED_SLOT_MODELS

    @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 `_AC_CONFIG_BLOCK_MODELS` (#162).
        """
        return self.device_type in _AC_CONFIG_BLOCK_MODELS

    @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
        `_SMART_LOAD_CAPABLE_MODELS` (#179).
        """
        return self.device_type in _SMART_LOAD_CAPABLE_MODELS

    @property
    def is_ems(self) -> bool:
        """Return True if this system is an EMS plant controller (HR/IR 2040-range)."""
        return self.device_type in (Model.EMS, Model.EMS_COMMERCIAL)

    @property
    def is_gateway(self) -> bool:
        """Return True if this system is a Gateway (IR 1600-range)."""
        return self.device_type == Model.GATEWAY

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 _AC_CONFIG_BLOCK_MODELS (#162).

has_extended_slots property

Return True if this system supports the extended 10-slot map (HR 240–299).

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 _SMART_LOAD_CAPABLE_MODELS (#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
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
@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 [])],
        lv_bcu_address=(
            _addr(normalised["lv_bcu_address"]) if normalised.get("lv_bcu_address") is not None else None
        ),
    )

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
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
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],
        "lv_bcu_address": f"0x{self.lv_bcu_address:02x}" if self.lv_bcu_address is not None else None,
    }

BatteryCalibrationStage

Bases: int, Enum

Battery calibration stages.

Source code in givenergy_modbus/model/inverter.py
266
267
268
269
270
271
272
273
274
275
276
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
259
260
261
262
263
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
286
287
288
289
290
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
329
330
331
332
333
334
335
336
337
338
339
340
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
393
394
395
396
397
398
399
400
401
402
403
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
356
357
358
359
360
361
362
363
364
365
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
343
344
345
346
347
348
349
350
351
352
353
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
279
280
281
282
283
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
379
380
381
382
383
384
385
386
387
388
389
390
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
293
294
295
296
297
298
299
300
301
302
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
 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 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]

    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). `_BATTERY_ENERGY_SOURCE` 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))
        alt = _BATTERY_ENERGY_SOURCE.get(model, {}).get(metric)
        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."""
        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 model in (Model.ALL_IN_ONE, Model.HYBRID_GEN4, Model.HYBRID_HV_GEN3):
            return EXTENDED_SLOTS
        if model is Model.HYBRID_GEN3 and int(arm_fw) > 302:
            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).
        """
        return self.model in AC_COUPLED_MODELS  # 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).
        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

    # 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().
    # 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]

    # Plain @property so the deprecated alias doesn't appear in model_dump().
    # IR(44) was decoded as e_inverter_out_day (GivTCP-era guess); sentinel
    # cross-correlation (#174) confirmed it is PV-generation-today. Renamed to
    # e_pv_generation_today; this alias preserves back-compat for a release.
    @property
    def e_inverter_out_day(self) -> float | None:
        """Deprecated alias for `e_pv_generation_today`."""
        warnings.warn(
            "SinglePhaseInverter.e_inverter_out_day is deprecated; use e_pv_generation_today",
            DeprecationWarning,
            stacklevel=2,
        )
        return self.e_pv_generation_today  # type: ignore[attr-defined,no-any-return]

    # IR(45/46) was decoded as e_inverter_out_total (GivTCP-era guess); the same
    # sentinel cross-correlation (#174) confirmed it is PV-generation-total. Renamed
    # to e_pv_generation_total; this alias preserves back-compat for a release.
    # Single-phase only: ThreePhaseInverter keeps its native e_inverter_out_total
    # (IR1362/3), which is a genuine register there, so no alias is defined on 3ph.
    @property
    def e_inverter_out_total(self) -> float | None:
        """Deprecated alias for `e_pv_generation_total`."""
        warnings.warn(
            "SinglePhaseInverter.e_inverter_out_total is deprecated; use e_pv_generation_total",
            DeprecationWarning,
            stacklevel=2,
        )
        return self.e_pv_generation_total  # 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). Returns None if any input is unavailable.

e_inverter_out_day property

Deprecated alias for e_pv_generation_today.

e_inverter_out_total property

Deprecated alias for e_pv_generation_total.

enable_standard_self_consumption_logic property

Deprecated alias for enable_inverter_parallel_mode.

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.

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
794
795
796
797
798
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
783
784
785
786
@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
788
789
790
791
792
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
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
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)),
        # skip protection settings HR(63-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)),
        # skip voltage adjustment settings 99-104
        "battery_voltage_adjust": Def(C.uint16, C.centi, HR(105)),
        # skip voltage adjustment settings 106-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)),
        "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)),
        # 'island_check_continue': Def(C.uint16, None, HR(115)),
        "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)),
        # skip PF configuration and protection settings 129-162
        "inverter_reboot": Def(C.uint16, None, HR(163)),
        "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
        # Single Phase New registers
        # This block is polled for non-EMS / non-gateway inverters; see client.py.
        #
        # HR(311): export priority — confirmed writable on Model.AC via portal observations
        # (hass#52): values 0/1/2 matched "Battery First / Grid First / Load First".
        "export_priority": Def(C.uint16, ExportPriority, HR(311)),
        "battery_charge_limit_ac": Def(C.uint16, None, HR(313)),
        "battery_discharge_limit_ac": Def(C.uint16, None, HR(314)),
        # 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)),
        #
        # 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 _BATTERY_ENERGY_SOURCE 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
        #
        "e_inverter_export_total": Def(C.uint32, None, 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)),
        "fault_code": Def(C.uint32, (C.hex, 8), IR(39), IR(40)),
        "t_inverter_heatsink": Def(C.deci, None, 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 (both
        # sentinel cross-correlation, #174), not the inverter AC output. Renamed from
        # "e_inverter_out_day" / "e_inverter_out_total"; the old names are kept as
        # deprecated @property aliases for a release.
        # 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)),
        "v_battery": Def(C.centi, None, IR(50), min=0.0, max=100.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.deci, None, IR(55), min=-40.0, max=100.0),
        "t_battery": Def(C.deci, None, IR(56), min=-40.0, max=100.0),
        "charger_warning_code": Def(C.uint16, None, 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
305
306
307
308
309
310
311
312
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
251
252
253
254
255
256
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
315
316
317
318
319
320
321
322
323
324
325
326
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

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
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
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
216
217
218
219
220
221
222
223
224
225
226
227
228
229
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
105
106
107
108
109
110
111
112
113
114
115
116
117
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]

from_register_cache(register_cache) classmethod

Construct a Battery from a RegisterCache.

Source code in givenergy_modbus/model/battery.py
110
111
112
113
@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
115
116
117
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
158
159
160
161
162
163
164
165
166
167
168
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
145
146
147
148
149
150
151
152
153
154
155
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
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),
        # Temperature `min=-60.0` also (incidentally) rejects the absent-battery-slot sentinel.
        # The BMS firmware stores temperatures internally with a `+2730` bias and subtracts
        # 2730 on TX, so an empty slot (internal `0`) emits `0xF556 = -2730 = -273.0 °C`.
        # See open-giv/bms-analysis docs/03 ("Absent device pattern") for the firmware path.
        # If you tighten these bounds, keep the lower bound below `-273.0` or add explicit
        # sentinel rejection — otherwise empty-slot frames will start reaching consumers.
        "t_cells_01_04": Def(DT.deci, None, IR(76), min=-60.0, max=150.0),
        "t_cells_05_08": Def(DT.deci, None, IR(77), min=-60.0, max=150.0),
        "t_cells_09_12": Def(DT.deci, None, IR(78), min=-60.0, max=150.0),
        "t_cells_13_16": Def(DT.deci, None, 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.deci, None, 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.deci, None, IR(103), min=-60.0, max=150.0),
        "t_min": Def(DT.deci, None, 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.

Confirmed writable on Model.AC via direct portal observations (hass#52): HR(311) was written with values 0/1/2 while the portal's "Export Priority" control was cycled through its three options.

Source code in givenergy_modbus/model/battery.py
132
133
134
135
136
137
138
139
140
141
142
class ExportPriority(IntEnum):
    """Dispatch priority for surplus power on AC-coupled inverters.

    Confirmed writable on Model.AC via direct portal observations (hass#52):
    HR(311) was written with values 0/1/2 while the portal's "Export Priority"
    control was cycled through its three options.
    """

    BATTERY_FIRST = 0
    GRID_FIRST = 1
    LOAD_FIRST = 2

State

Bases: IntEnum

Battery charge/discharge state.

Source code in givenergy_modbus/model/battery.py
120
121
122
123
124
125
126
127
128
129
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
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
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
240
241
242
243
244
245
246
247
248
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
251
252
253
254
255
256
257
258
259
260
261
262
263
class WriteHoldingRegisterRequest(WriteHoldingRegister, TransparentRequest):
    """Concrete PDU implementation for handling function #6/Write Holding Register request messages."""

    def ensure_valid_state(self):
        """Sanity check our internal state."""
        super().ensure_valid_state()
        if 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
254
255
256
257
258
def ensure_valid_state(self):
    """Sanity check our internal state."""
    super().ensure_valid_state()
    if 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
266
267
268
269
270
271
272
273
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()
        if self.register not in WRITE_SAFE_REGISTERS 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
269
270
271
272
273
def ensure_valid_state(self):
    """Sanity check our internal state."""
    super().ensure_valid_state()
    if self.register not in WRITE_SAFE_REGISTERS 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."""

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
41
42
43
44
45
46
47
48
49
50
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
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
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
76
77
78
79
80
81
82
83
84
85
86
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
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
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
124
125
126
127
128
129
130
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
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
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