Repository navigation
Expand file tree
/
Copy pathgateway.py
More file actions
3362 lines (2953 loc) · 183 KB
/
Copy pathgateway.py
File metadata and controls
3362 lines (2953 loc) · 183 KB
1
2
3
4
5
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
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
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
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
"""ESP32 Gateway MQTT component.
Provides full inverter telemetry and control via the ESP32 gateway's
MQTT interface. Registered in COMPONENT_LIST as 'gateway'. This is
the sole data source and control interface for SaaS users with a
gateway — no Home Assistant in the loop.
"""
import asyncio
import datetime
import json
import math
import os
import ssl
import threading
import time
import uuid
import traceback
from utils import calc_percent_limit, export_mode_of, export_target_of, export_power_of, parse_car_plan_windows, in_car_plan_window
from const import EXPORT_MODE_FREEZE, EXPORT_MODE_IDLE, MINUTE_WATT
import pytz as _pytz
from component_base import ComponentBase
from control_handoff import control_gate_for, gated_mqtt_publish
try:
import gateway_status_pb2 as pb
from google.protobuf import json_format as _pb_json_format
HAS_PROTOBUF = True
except (ImportError, Exception):
pb = None
_pb_json_format = None
HAS_PROTOBUF = False
try:
import aiohttp
HAS_AIOHTTP = True
except ImportError:
aiohttp = None
HAS_AIOHTTP = False
try:
import aiomqtt
HAS_AIOMQTT = True
except ImportError:
HAS_AIOMQTT = False
if not HAS_PROTOBUF:
raise ImportError("GatewayMQTT requires the 'protobuf' package: pip install protobuf")
# Plan re-publish interval (seconds)
_PLAN_REPUBLISH_INTERVAL = 5 * 60
# Telemetry staleness threshold (seconds)
_TELEMETRY_STALE_THRESHOLD = 120
# How long an identical control command waits for the hub's ack (or, after an ack,
# for telemetry to catch up) before it may be published again (seconds). Covers the
# GWMQTT write_and_poll loop (3 attempts of up to 10 s each, see INVERTER_DEF), so one
# write sends the command once.
_COMMAND_ACK_WINDOW = 30
# Ids kept per tracked command, so an ack for an earlier send of the same value still
# counts (matches the number of recent ids the hub remembers for replay protection)
_COMMAND_ACK_IDS_KEPT = 16
def _monotonic():
"""Clock for the ack window: immune to wall-clock corrections, and patchable in tests."""
return time.monotonic()
# Total startup wait budget, in 0.5 s ticks, shared by the connection and auto-config waits
_STARTUP_WAIT_TICKS = 120 * 2
_STARTUP_WAIT_SECONDS = _STARTUP_WAIT_TICKS * 0.5
# Time options for schedule select entities (HH:MM:SS, one per minute across 24 h)
_GATEWAY_BASE_TIME = datetime.datetime.strptime("00:00", "%H:%M")
_GATEWAY_OPTIONS_TIME = [(_GATEWAY_BASE_TIME + datetime.timedelta(seconds=m * 60)).strftime("%H:%M:%S") for m in range(0, 24 * 60, 5)]
def _serial_suffix(serial):
"""Return the entity-name suffix for an inverter serial.
Entity IDs use the last six characters of the serial, lower-cased; serials of
six characters or fewer are used whole.
Args:
serial: Inverter serial string.
Returns:
str: The lower-cased suffix used in gateway entity IDs.
"""
return serial[-6:].lower() if len(serial) > 6 else serial.lower()
PLAN_MODE_AUTO = 0
PLAN_MODE_CHARGE = 1
PLAN_MODE_DISCHARGE = 2
# GivEnergy holds the charge and discharge rates as a whole percent of nominal battery capacity,
# rounded down - 1300W on a 13.41kWh battery reads back 1206W (#5324)
GIVENERGY_RATE_STEP_PERCENT_OF_CAPACITY = 1
GIVENERGY_INVERTER_TYPES = (pb.INVERTER_TYPE_GIVENERGY, pb.INVERTER_TYPE_GIVENERGY_EMS, pb.INVERTER_TYPE_GIVENERGY_GATEWAY)
# Coordinators: a Plant EMS or a GivEnergy Gateway manages battery inverters and has no battery of its own
GATEWAY_COORDINATOR_TYPES = (pb.INVERTER_TYPE_GIVENERGY_EMS, pb.INVERTER_TYPE_GIVENERGY_GATEWAY)
# Where the discovery record of each inverter in gateway_inverter_serial is kept (see _update_discovery)
GATEWAY_STORAGE_MODULE = "gateway"
# How long a failed save of the discovery records waits before it is tried again
GATEWAY_DISCOVERY_SAVE_RETRY_SECONDS = 5 * 60
# Entity attribute table — keyed by the semantic suffix used in dashboard_item calls
GATEWAY_ATTRIBUTE_TABLE = {
# Binary sensors
"gateway_online": {"friendly_name": "Gateway Online", "device_class": "connectivity"},
"gateway_read_only": {"friendly_name": "Gateway Read-Only (enforced)", "icon": "mdi:lock"},
# Timestamps
"inverter_time": {"friendly_name": "Inverter Time", "icon": "mdi:clock", "device_class": "timestamp"},
# Battery state
"soc": {"friendly_name": "Battery SOC", "icon": "mdi:battery", "unit_of_measurement": "%", "device_class": "battery", "state_class": "measurement"},
"battery_power": {"friendly_name": "Battery Power", "icon": "mdi:battery", "unit_of_measurement": "W", "device_class": "power", "state_class": "measurement"},
"battery_voltage": {"friendly_name": "Battery Voltage", "icon": "mdi:battery", "unit_of_measurement": "V", "device_class": "voltage", "state_class": "measurement"},
"battery_current": {"friendly_name": "Battery Current", "icon": "mdi:battery", "unit_of_measurement": "A", "device_class": "current", "state_class": "measurement"},
"battery_temperature": {"friendly_name": "Battery Temperature", "icon": "mdi:thermometer", "unit_of_measurement": "°C", "device_class": "temperature", "state_class": "measurement"},
"battery_capacity": {"friendly_name": "Battery Capacity", "icon": "mdi:battery", "unit_of_measurement": "kWh", "device_class": "energy"},
"battery_soh": {"friendly_name": "Battery State of Health", "icon": "mdi:battery-heart", "unit_of_measurement": "%", "state_class": "measurement"},
"battery_rate_max": {"friendly_name": "Battery Max Charge Rate", "icon": "mdi:battery", "unit_of_measurement": "W", "device_class": "power"},
"battery_dod": {"friendly_name": "Battery Depth of Discharge", "icon": "mdi:battery", "unit_of_measurement": "*"},
"battery_charge_today": {"friendly_name": "Battery Charge Today", "icon": "mdi:battery-plus", "unit_of_measurement": "kWh", "device_class": "energy", "state_class": "total"},
"battery_discharge_today": {"friendly_name": "Battery Discharge Today", "icon": "mdi:battery-minus", "unit_of_measurement": "kWh", "device_class": "energy", "state_class": "total"},
# PV
"pv_power": {"friendly_name": "PV Power", "icon": "mdi:solar-power", "unit_of_measurement": "W", "device_class": "power", "state_class": "measurement"},
"pv_today": {"friendly_name": "PV Today", "icon": "mdi:solar-power", "unit_of_measurement": "kWh", "device_class": "energy", "state_class": "total"},
# Grid
"grid_power": {"friendly_name": "Grid Power", "icon": "mdi:transmission-tower", "unit_of_measurement": "W", "device_class": "power", "state_class": "measurement"},
"grid_voltage": {"friendly_name": "Grid Voltage", "icon": "mdi:transmission-tower", "unit_of_measurement": "V", "device_class": "voltage", "state_class": "measurement"},
"grid_frequency": {"friendly_name": "Grid Frequency", "icon": "mdi:transmission-tower", "unit_of_measurement": "Hz", "device_class": "frequency", "state_class": "measurement"},
"import_today": {"friendly_name": "Grid Import Today", "icon": "mdi:transmission-tower", "unit_of_measurement": "kWh", "device_class": "energy", "state_class": "total"},
"export_today": {"friendly_name": "Grid Export Today", "icon": "mdi:transmission-tower", "unit_of_measurement": "kWh", "device_class": "energy", "state_class": "total"},
# Load
"load_power": {"friendly_name": "Load Power", "icon": "mdi:flash", "unit_of_measurement": "W", "device_class": "power", "state_class": "measurement"},
"load_today": {"friendly_name": "Load Today", "icon": "mdi:flash", "unit_of_measurement": "kWh", "device_class": "energy", "state_class": "total"},
# Inverter
"inverter_power": {"friendly_name": "Inverter Power", "icon": "mdi:flash", "unit_of_measurement": "W", "device_class": "power", "state_class": "measurement"},
"inverter_temperature": {"friendly_name": "Inverter Temperature", "icon": "mdi:thermometer", "unit_of_measurement": "°C", "device_class": "temperature", "state_class": "measurement"},
"inverter_rate_max": {"friendly_name": "Inverter Max Rate", "icon": "mdi:flash", "unit_of_measurement": "W", "device_class": "power"},
# Control switches
"charge_enabled": {"friendly_name": "Charge Enabled", "icon": "mdi:battery-plus"},
"discharge_enabled": {"friendly_name": "Discharge Enabled", "icon": "mdi:battery-minus"},
# Control numbers
"export_limit_w": {"friendly_name": "Export Limit", "icon": "mdi:transmission-tower", "unit_of_measurement": "W", "device_class": "power"},
"charge_rate": {"friendly_name": "Charge Rate", "icon": "mdi:battery-plus", "unit_of_measurement": "W", "min": 0, "max": 10000, "step": 10},
"discharge_rate": {"friendly_name": "Discharge Rate", "icon": "mdi:battery-minus", "unit_of_measurement": "W", "min": 0, "max": 10000, "step": 10},
"reserve_soc": {"friendly_name": "Reserve SOC", "icon": "mdi:battery-lock", "unit_of_measurement": "%", "min": 0, "max": 100, "step": 1},
"target_soc": {"friendly_name": "Target SOC", "icon": "mdi:battery-arrow-up", "unit_of_measurement": "%", "min": 0, "max": 100, "step": 1},
# Schedule selects (HH:MM:SS options)
"charge_slot1_start": {"friendly_name": "Charge Slot 1 Start", "icon": "mdi:clock-start", "options": _GATEWAY_OPTIONS_TIME},
"charge_slot1_end": {"friendly_name": "Charge Slot 1 End", "icon": "mdi:clock-end", "options": _GATEWAY_OPTIONS_TIME},
"discharge_slot1_start": {"friendly_name": "Discharge Slot 1 Start", "icon": "mdi:clock-start", "options": _GATEWAY_OPTIONS_TIME},
"discharge_slot1_end": {"friendly_name": "Discharge Slot 1 End", "icon": "mdi:clock-end", "options": _GATEWAY_OPTIONS_TIME},
# EMS aggregate entities
"ems_total_soc": {"friendly_name": "EMS Total SOC", "icon": "mdi:battery", "unit_of_measurement": "%", "device_class": "battery", "state_class": "measurement"},
"ems_total_charge": {"friendly_name": "EMS Total Charge Power", "icon": "mdi:battery-plus", "unit_of_measurement": "W", "device_class": "power", "state_class": "measurement"},
"ems_total_discharge": {"friendly_name": "EMS Total Discharge Power", "icon": "mdi:battery-minus", "unit_of_measurement": "W", "device_class": "power", "state_class": "measurement"},
"ems_total_grid": {"friendly_name": "EMS Total Grid Power", "icon": "mdi:transmission-tower", "unit_of_measurement": "W", "device_class": "power", "state_class": "measurement"},
"ems_total_pv": {"friendly_name": "EMS Total PV Power", "icon": "mdi:solar-power", "unit_of_measurement": "W", "device_class": "power", "state_class": "measurement"},
"ems_total_load": {"friendly_name": "EMS Total Load Power", "icon": "mdi:flash", "unit_of_measurement": "W", "device_class": "power", "state_class": "measurement"},
# Sub-inverter temperature (entity suffix is "_temp")
"temp": {"friendly_name": "Sub-Inverter Temperature", "icon": "mdi:thermometer", "unit_of_measurement": "°C", "device_class": "temperature", "state_class": "measurement"},
# EV charger (OCPP) — device-level entities, present when a charge point is connected
"ev_online": {"friendly_name": "EV Charger Online", "icon": "mdi:ev-station", "device_class": "connectivity"},
"ev_connected": {"friendly_name": "EV Car Connected", "icon": "mdi:car-electric", "device_class": "plug"},
"ev_session_active": {"friendly_name": "EV Charging Active", "icon": "mdi:ev-station", "device_class": "battery_charging"},
"ev_charging": {"friendly_name": "EV Drawing Power", "icon": "mdi:ev-station", "device_class": "battery_charging"},
"ev_status": {"friendly_name": "EV Charger Status", "icon": "mdi:ev-station"},
"ev_power": {"friendly_name": "EV Charge Power", "icon": "mdi:ev-station", "unit_of_measurement": "W", "device_class": "power", "state_class": "measurement"},
"ev_session_energy": {"friendly_name": "EV Session Energy", "icon": "mdi:ev-station", "unit_of_measurement": "kWh", "device_class": "energy", "state_class": "total"},
"ev_soc": {"friendly_name": "EV Battery SOC", "icon": "mdi:car-electric", "unit_of_measurement": "%", "device_class": "battery", "state_class": "measurement"},
"ev_current_limit": {"friendly_name": "EV Current Limit", "icon": "mdi:current-ac", "unit_of_measurement": "A", "device_class": "current"},
"ev_max_current": {"friendly_name": "EV Max Current", "icon": "mdi:current-ac", "unit_of_measurement": "A", "device_class": "current"},
"ev_voltage": {"friendly_name": "EV Supply Voltage", "icon": "mdi:flash", "unit_of_measurement": "V", "device_class": "voltage", "state_class": "measurement"},
"ev_eco_mode": {"friendly_name": "EV Eco Mode", "icon": "mdi:leaf"},
"ev_charge_rate": {"friendly_name": "EV Charge Rate", "icon": "mdi:ev-station", "unit_of_measurement": "kW", "device_class": "power"},
}
def extract_rate_anchors(rate_min, rate_max, import_rate, export_rate):
"""Validate and round the four rate anchors for the device payload.
Returns a dict with rate_min / rate_max / import_rate / export_rate rounded
to 0.01 p/kWh (matching the marginal-cost matrix precision), or None if any
value is missing, non-numeric, or non-finite (NaN/Inf) so the device falls
back to its plan-aware RAG and the payload never carries invalid JSON
tokens.
"""
try:
rate_min = round(float(rate_min), 2)
rate_max = round(float(rate_max), 2)
rate_import = round(float(import_rate), 2)
rate_export = round(float(export_rate), 2)
except (TypeError, ValueError):
return None
if not (math.isfinite(rate_min) and math.isfinite(rate_max) and math.isfinite(rate_import) and math.isfinite(rate_export)):
return None
return {"rate_min": rate_min, "rate_max": rate_max, "import_rate": rate_import, "export_rate": rate_export}
# gateway_integrate_power: power is not taken to have held across a telemetry gap longer than this,
# so nothing is integrated over it
GATEWAY_INTEGRATE_MAX_GAP_SECONDS = 15 * 60
# gateway_integrate_power: the most PV a hub pv_today counter can gain between two samples, as a rate
# plus one counter step. A larger rise is the counter returning from a dropped sample, not generation
GATEWAY_INTEGRATE_MAX_PV_W = 50000
GATEWAY_INTEGRATE_PV_STEP_WH = 200
class GatewayMQTT(ComponentBase):
"""ESP32 Gateway MQTT component for PredBat.
Static methods handle data transformation (protobuf <-> entities/commands).
Instance methods handle MQTT lifecycle and ComponentBase integration.
"""
# Guards the command id counter and the in-flight command map, which are touched both by
# control writes (engine thread) and by acks arriving on the MQTT listener's loop.
_command_lock = threading.Lock()
# Defaults for an instance built without initialize()
gateway_shared_ct = True
gateway_integrate_power = False
gateway_inverter_serial = ()
gateway_display_only = False
_integrated_energy = None
_unavailable_inverters = ((), ())
_discovery = {} # replaced, never changed in place, so this default is not shared state
_discovery_dirty = False
_discovery_save_retry_at = 0
_pv_only_serials = ()
_inverter_slot_serials = ()
_slots_without_capacity = frozenset()
_last_ratings = {} # replaced, never changed in place
_assumed_ratings = frozenset()
_reserve_before_zero = {} # replaced, never changed in place
gateway_device_id = None
def initialize(
self,
gateway_device_id=None,
mqtt_host=None,
mqtt_port=8883,
mqtt_token=None,
gateway_inverter_serial=None,
gateway_evc_automatic=False,
gateway_evc_control=False,
gateway_shared_ct=True,
gateway_integrate_power=False,
gateway_display_only=False,
**kwargs,
):
"""Initialize gateway configuration and build MQTT topic strings.
Args:
gateway_device_id: The gateway's device ID (e.g. "pbgw_abc123").
gateway_display_only: Publish display data without registering or controlling any inverter
or EV charger. The hub must already be parked by its standby controller.
mqtt_host: MQTT broker hostname.
mqtt_port: MQTT broker port (default 8883 for TLS).
mqtt_token: JWT access token for MQTT authentication.
gateway_inverter_serial: Optional serial number(s) to restrict which inverters are configured.
If not set, all inverters are used. May be a string or a list of strings.
gateway_evc_automatic: When True, automatically register a connected OCPP EV charger as a
PredBat car so the optimizer plans for it. Off by default so existing gateway users are
unaffected. Set to ``true`` in apps.yaml to enable.
gateway_evc_control: When True (requires gateway_evc_automatic), check once per minute whether
the current time falls inside a planned car-charging window and send RemoteStartTransaction
plus SetChargingProfile on window entry, or RemoteStopTransaction on window exit.
gateway_shared_ct: When True (the default), several inverters are taken to share one grid CT
clamp, so each reports the same grid and load. Only the first inverter's grid and load
readings are then used, so the house's grid and load are not counted once per inverter.
Set to False where each inverter has its own clamp, and the readings are summed.
gateway_integrate_power: When True, today's import, export and load are integrated from grid
and battery power instead of taken from the hub's energy counters, for inverters whose
counters cannot be trusted (see _update_integrated_energy()). Off by default.
**kwargs: Additional keyword arguments (ignored).
"""
self.gateway_device_id = gateway_device_id
if isinstance(gateway_display_only, str):
value = gateway_display_only.strip().lower()
if value not in ("true", "false", "1", "0", "yes", "no", "on", "off"):
raise ValueError("gateway_display_only must be a boolean")
gateway_display_only = value in ("true", "1", "yes", "on")
if not isinstance(gateway_display_only, bool):
raise ValueError("gateway_display_only must be a boolean")
self.gateway_display_only = gateway_display_only
self.gateway_evc_automatic = bool(gateway_evc_automatic)
self.gateway_evc_control = bool(gateway_evc_control)
self.gateway_shared_ct = bool(gateway_shared_ct)
self.gateway_integrate_power = bool(gateway_integrate_power)
# Today's import, export and load integrated from power while gateway_integrate_power is on
# (see _update_integrated_energy()). None while it is off or no inverter is bound yet.
self._integrated_energy = None
self.mqtt_host = mqtt_host
self.mqtt_port = mqtt_port
self.mqtt_token = mqtt_token
# Normalise the serial filter to a list. Whitespace-only values are
# equivalent to an unset filter, including when they arrive inside a
# list or a JSON-encoded list.
if gateway_inverter_serial is None:
serials = []
elif isinstance(gateway_inverter_serial, list):
serials = gateway_inverter_serial
else:
serial_value = gateway_inverter_serial.strip() if isinstance(gateway_inverter_serial, str) else gateway_inverter_serial
if isinstance(serial_value, str) and (serial_value.startswith("{") or serial_value.startswith("[")):
try:
parsed = json.loads(serial_value)
if isinstance(parsed, list):
serials = parsed
else:
serials = [parsed]
except json.JSONDecodeError:
serials = [serial_value]
else:
serials = [serial_value]
self.gateway_inverter_serial = [serial for value in serials if value is not None and (serial := str(value).strip())]
self.mqtt_token_expires_at = 0
# MQTT topic strings
self._topic_base = f"predbat/devices/{gateway_device_id}" if gateway_device_id else "predbat/devices/unknown"
self.topic_status = f"{self._topic_base}/status"
self.topic_online = f"{self._topic_base}/online"
self.topic_schedule = f"{self._topic_base}/schedule"
self.topic_command = f"{self._topic_base}/command"
self.topic_ev_command = f"{self._topic_base}/ev/command"
self.topic_ack = f"{self._topic_base}/ack/+"
self._ack_topic_prefix = f"{self._topic_base}/ack/"
# Runtime state
self._mqtt_client = None
self._mqtt_task = None
self._mqtt_connected = False
# Event loop that owns self._mqtt_client (captured in run()). Control writes
# arrive via ha.py::run_async(), which runs on its own throwaway loop on the
# calling thread — publishing directly from there would bind the aiomqtt
# publish-confirmation Future to the wrong loop and stall for the client's
# ~10s default timeout instead of completing near-instantly. _publish_raw()
# dispatches onto this loop when called from elsewhere.
self._loop = None
self._gateway_online = False
self._last_telemetry_time = 0
self._last_plan_data = None
self._last_plan_publish_time = 0
# Entries and timezone of the last built plan, kept so the periodic re-publish
# can rebuild the protobuf with a fresh timestamp (re-sending the cached bytes
# would keep the original timestamp and the device would think the plan is stale)
self._last_plan_entries = None
self._last_plan_timezone = None
self._plan_version = 0
self._refresh_in_progress = False
self._error_count = 0
# Entity naming prefix
self.prefix = "predbat"
# Auto-config state
self._last_status = None
self._auto_configured = False
self._configured_inverter_serials = frozenset() # serials discovered at the last auto-config
self._inverter_slot_serials = [] # serial bound to each PredBat inverter slot at the last auto-config
self._pv_only_serials = () # listed inverters with no battery: counted for PV, never written to
self._slots_without_capacity = frozenset() # slots bound with no battery size sensor, re-bound once the hub reports a size
self._last_ratings = {} # (entity suffix, rating name) -> the last real value the hub reported
self._assumed_ratings = frozenset() # entity suffixes of inverters whose maximum rate has never been reported
self._reserve_before_zero = {} # entity suffix -> the last real reserve, set aside when Predbat wrote a reserve of 0
self._discovery = {} # upper-case serial -> what the hub last reported it to be (see _update_discovery)
self._discovery_dirty = False
self._discovery_save_retry_at = 0
self._unavailable_inverters = ((), ())
self._configured_ev_chargers = frozenset() # EV charge point ids registered at the last auto-config
self._last_published_plan = None
self._pending_plan = None
self._ev_windows: list = [] # parsed (start_dt, end_dt) charge windows from HA plan attribute
self._ev_charging_active: bool = False # last commanded state; avoids duplicate start/stop sends
self._ev_max_current: dict = {} # charge_point_id → last known max_current_a from telemetry
self._suffix_to_serial = {} # maps entity suffix (last 6 chars of serial) -> full serial string
# Incrementing counter included in every published command, seeded from the clock so
# ids do not restart at PBAT1 after a restart: the hub rejects ids it has recently seen
# as replays, and a late ack for the previous process's PBAT1 must not match ours.
self._command_id = int(time.time() * 1000)
# Command acks (predbat/devices/<id>/ack/<command_id>). Control writes are
# tracked per entity so an identical re-send can wait for the hub's ack
# instead of queueing another Modbus write every poll. See _send_control().
self._pending_commands = {} # entity_id -> {command_ids, outcomes, command, kwargs, state, sent_at, resent, cached (id that updated the cache)}
self._ack_subscribed = False # broker granted the ack subscription on this connection
self._acks_seen = False # at least one ack matched a tracked command on this connection
self._ack_subscribe_warned = False # the denied-subscription warning is logged once
# Predbat data publish state (price/timeline for device display)
self._last_predbat_data = None
# Track which inverter serials have received an inverter_reset command
self._inverter_reset_done = set()
# Rate caps last sent to the hub with set_rate_cap: serial -> (charge_w, discharge_w)
self._rate_caps_sent = {}
# Last read-only state sent to the gateway (None until the first send,
# so the current state is always pushed once on startup)
self._last_read_only = None
# Monotonic-ish timestamp of the last set_read_only send, used to force a
# periodic re-send so the gateway re-syncs even if a command was missed
self._last_read_only_sent_time = 0
# Latch so a gateway whose reported read-only state disagrees with what PredBat
# requested is logged once per transition, not on every status cycle
self._read_only_mismatch_logged = False
# Set once the first MQTT connection attempt has completed (success or failure)
self._first_connection_attempted = False
# Verbose telemetry/command logging for debugging. Enable by setting
# `gateway_debug: True` in apps.yaml; off by default to avoid log spam.
if isinstance(self.args, dict):
debug_val = self.args.get("gateway_debug", False)
self._debug = str(debug_val).strip().lower() in ["on", "true", "yes", "enabled", "enable", "connected", "1"]
else:
self._debug = False
# Register for plan execution hook so we receive plan updates generically
if hasattr(self.base, "register_hook"):
self.base.register_hook("on_plan_executed", self._on_plan_executed)
def _on_plan_executed(self, charge_windows=None, charge_limits=None, export_windows=None, export_limits=None, charge_rate_w=0, discharge_rate_w=0, soc_max=10, reserve=0, timezone="Europe/London"):
"""Handle plan execution hook — convert PredBat plan to gateway protobuf format.
Called by the plugin system after execute_plan() completes. Converts
the optimizer's charge/export windows into ExecutionPlan entries and
queues them for MQTT publishing on the next run() cycle.
"""
if self.gateway_display_only:
return
plan_entries = []
# Reserve as a percentage — used to detect freeze charge (charge limit == reserve)
reserve_percent = calc_percent_limit(reserve, soc_max)
# Convert charge windows to plan entries
for i, window in enumerate(charge_windows or []):
limit_kwh = charge_limits[i] if i < len(charge_limits or []) else soc_max
if limit_kwh <= 0:
continue
limit = calc_percent_limit(limit_kwh, soc_max)
charge_power_w = charge_rate_w
# Freeze charge (charge limit == reserve): hold the current SoC rather than
# charge to a target. There is no freeze mode, so express it as a charge entry
# with rate 0 and target 100% — the inverter holds (won't discharge) and solar
# may still charge it, but no grid charging happens.
if limit == reserve_percent:
limit = 100
charge_power_w = 0
start_minutes = window.get("start", 0)
end_minutes = window.get("end", 0)
# Work out hours and minutes
start_hour = start_minutes // 60
start_minute = start_minutes % 60
end_hour = end_minutes // 60
end_minute = end_minutes % 60
# Skip zero-duration windows (start == end after midnight wrap)
if start_hour == end_hour and start_minute == end_minute:
self.log(f"Warn: GatewayMQTT: Skipping zero-duration charge window {i} ({start_minutes}-{end_minutes})")
continue
plan_entries.append(
{
"enabled": True,
"start_hour": start_hour,
"start_minute": start_minute,
"end_hour": end_hour,
"end_minute": end_minute,
"mode": PLAN_MODE_CHARGE, # charge
"power_w": charge_power_w,
"target_soc": int(limit),
"days_of_week": 0x7F,
"use_native": True,
}
)
# Convert export/discharge windows to plan entries
for i, window in enumerate(export_windows or []):
limit = export_limits[i] if i < len(export_limits or []) else 0
mode = export_mode_of(limit)
if mode == EXPORT_MODE_IDLE:
continue
if mode == EXPORT_MODE_FREEZE:
# Freeze export: hold SoC and export only surplus PV rather than force-discharge.
# There is no freeze mode here, so express it as a discharge entry with rate 0
# and target = reserve.
target_soc = reserve_percent
export_power_w = 0
else:
# Low-power export: the planner encodes the chosen export rate in the limit and
# execute applies the same scaling (see plan.py / execute.py). With low power off
# there is no fraction, so this is full rate.
target_soc = export_target_of(limit)
export_power_w = round(discharge_rate_w * export_power_of(limit))
start_minutes = window.get("start", 0)
end_minutes = window.get("end", 0)
# Work out hours and minutes
start_hour = start_minutes // 60
start_minute = start_minutes % 60
end_hour = end_minutes // 60
end_minute = end_minutes % 60
# Skip zero-duration windows
if start_hour == end_hour and start_minute == end_minute:
self.log(f"Warn: GatewayMQTT: Skipping zero-duration discharge window {i} ({start_minutes}-{end_minutes})")
continue
plan_entries.append(
{
"enabled": True,
"start_hour": start_hour,
"start_minute": start_minute,
"end_hour": end_hour,
"end_minute": end_minute,
"mode": PLAN_MODE_DISCHARGE, # discharge
"power_w": export_power_w,
"target_soc": int(target_soc),
"days_of_week": 0x7F,
"use_native": True,
}
)
# Sort entries chronologically (earliest start first) — charge and export windows
# are appended in separate passes above, so without this the gateway would receive
# them grouped by type rather than in time order. Sorting before the cap also keeps
# the earliest slots when there are more than the firmware can hold. Tomorrow's
# windows keep hours >= 24 deliberately so they stay distinct from today's and don't
# overlap, and so the sort orders today's 22:00 before tomorrow's 06:00 (hour 30).
plan_entries.sort(key=lambda e: (e["start_hour"], e["start_minute"]))
# Cap at 6 entries (firmware PlanEntry entries[6] fixed array)
MAX_PLAN_ENTRIES = 6
if len(plan_entries) > MAX_PLAN_ENTRIES:
plan_entries = plan_entries[:MAX_PLAN_ENTRIES]
self.log(f"Info: GatewayMQTT: Plan entries ({len(plan_entries)}): " + ", ".join(f"mode={e['mode']} {e['start_hour']:02d}:{e['start_minute']:02d}-{e['end_hour']:02d}:{e['end_minute']:02d}" for e in plan_entries))
# Queue plan for async publishing (picked up by run() cycle)
if self._plan_changed(plan_entries):
self._pending_plan = (plan_entries, timezone)
def _refresh_ev_windows(self):
"""Re-read the PredBat car-charging-slot planned windows from HA and cache them.
Called once per run() cycle so the minute-level check always has up-to-date window
boundaries without polling HA every second. The ``planned`` attribute of
``binary_sensor.<prefix>_car_charging_slot`` is a list of ``{start, end, ...}`` dicts
with datetime strings in ``"%m-%d %H:%M:%S"`` format (produced by output.py).
Datetimes are localized using ``self.local_tz`` so comparisons respect the component
timezone and DST transitions. The plan carries no year; ``utils.parse_car_plan_windows()``
(shared with the other charger components) rebuilds it around now, so a window that straddles
New Year is anchored correctly whichever side of midnight it is read (#269).
"""
planned = self.get_state_wrapper(f"binary_sensor.{self.prefix}_car_charging_slot", attribute="planned") or []
now = datetime.datetime.now(self.local_tz)
self._ev_windows = parse_car_plan_windows(planned, now, self.local_tz)
def _should_ev_charge_now(self):
"""Return True if the current local time falls inside any planned charge window."""
return in_car_plan_window(self._ev_windows, datetime.datetime.now(self.local_tz))
async def _apply_ev_charging_state(self):
"""Start or stop EVC charging when the window state transitions.
Called every run() cycle (once per minute). On a start transition, sets the
charging profile to the configured max current then issues RemoteStartTransaction
so the charger begins a session at that rate. On a stop transition, issues
RemoteStopTransaction; the firmware falls back to its internal transaction ID when
none is supplied. No command is sent when the desired state already matches
``self._ev_charging_active``.
"""
if not self._configured_ev_chargers:
return
should_charge = self._should_ev_charge_now()
if should_charge == self._ev_charging_active:
return
# min() gives a deterministic choice when multiple chargers are present
cp_id = min(self._configured_ev_chargers)
if should_charge:
current_a = self._ev_max_current.get(cp_id, 32)
self.log(f"Info: GatewayMQTT: EVC start charging — current_a={current_a} for '{cp_id}'")
await self._send_ev_command({"action": "SetChargingProfile", "current_a": int(current_a)})
await self._send_ev_command({"action": "RemoteStartTransaction", "id_tag": "predbat"})
else:
self.log(f"Info: GatewayMQTT: EVC stop charging for '{cp_id}'")
await self._send_ev_command({"action": "RemoteStopTransaction"})
self._ev_charging_active = should_charge
async def _send_ev_command(self, command):
"""Publish a single EV command dict as JSON to the EV command topic.
Args:
command: Dict with ``action`` key and any command-specific fields.
"""
await self._publish_raw(self.topic_ev_command, json.dumps(command).encode())
async def _startup_wait(self, ticks, ready):
"""Poll ``ready`` at 0.5 s intervals until it is true or the startup budget is spent.
Args:
ticks: Ticks already consumed by earlier waits in this startup.
ready: Zero-argument callable returning True once the wait can end.
Returns:
int: The total ticks consumed, to pass to the next wait in the chain.
"""
while ticks < _STARTUP_WAIT_TICKS and not self.api_stop and not ready():
await asyncio.sleep(0.5)
ticks += 1
return ticks
async def run(self, seconds, first):
"""Component run loop — called every 60 seconds by ComponentBase.start().
On the first call, starts the background MQTT listener task.
Subsequent calls perform housekeeping: token refresh checks and
plan re-publishing if stale.
Args:
seconds: Elapsed seconds since component start.
first: True on the first invocation.
Returns:
True on success, False on failure.
"""
if not HAS_AIOMQTT:
self.log("Error: GatewayMQTT: aiomqtt not installed — cannot start")
return False
if not self.gateway_device_id or not self.mqtt_host:
self.log("Error: GatewayMQTT: gateway_device_id and mqtt_host are required")
return False
if first:
# Capture the loop that will own the MQTT client/listener task, so
# cross-loop callers (ha.py::run_async()) can dispatch onto it later.
self._loop = asyncio.get_running_loop()
await self._load_discovery()
# Start MQTT listener as a background task
self._mqtt_task = asyncio.ensure_future(self._mqtt_loop())
self.log("Info: GatewayMQTT: MQTT listener task started")
if self.gateway_display_only:
# The selected inverter provider must not wait for an optional
# display's broker connection or first telemetry. Component
# startup and actual display availability are separate states.
self.api_started = True
self._update_display_health()
return True
# Wait for the first connection attempt and then for the first telemetry →
# auto-config, so the inverter args are wired up before startup continues.
# Both waits share the single _STARTUP_WAIT_TICKS budget so an offline gateway
# device can never stall startup for longer than that.
ticks = await self._startup_wait(0, lambda: self._first_connection_attempted)
if not self._first_connection_attempted and not self.api_stop:
self.log("Warn: GatewayMQTT: First connection attempt not yet complete, continuing startup")
# The auto-config wait runs even when the first attempt failed: the MQTT loop
# retries with backoff in the background, so a transient broker failure at
# startup usually still yields telemetry inside the window. Returning early
# here declares the component started with no inverter args set, which lets
# PredBat's startup race ahead of auto-config.
await self._startup_wait(ticks, lambda: self._auto_configured)
if not self._auto_configured and not self.api_stop:
self.log(f"Warn: GatewayMQTT: Auto-config not complete after {_STARTUP_WAIT_SECONDS:.0f}s — gateway device may be offline or MQTT broker unreachable, continuing startup")
return True
# Housekeeping on subsequent runs
try:
# Check if MQTT task died unexpectedly
if self._mqtt_task and self._mqtt_task.done():
exc = self._mqtt_task.exception() if not self._mqtt_task.cancelled() else None
if exc:
self.log(f"Warn: GatewayMQTT: MQTT task died with: {exc}")
self.log("Info: GatewayMQTT: Restarting MQTT listener task")
self._mqtt_task = asyncio.ensure_future(self._mqtt_loop())
if self.gateway_display_only:
# The JSON display topic is separate from the executable schedule. Never
# refresh schedules, release read-only, reset inverters or control EVs here.
if self._mqtt_connected and self._last_status is not None:
await self._publish_predbat_data()
await self._check_token_refresh()
self._update_display_health()
return True
# Publish any queued plan from on_plan_executed hook
if self._pending_plan:
plan_entries, tz = self._pending_plan
self._pending_plan = None
await self.publish_plan(plan_entries, tz)
# EVC minute-level control: refresh windows from HA plan, then start/stop if needed
if self.gateway_evc_control and self._auto_configured:
self._refresh_ev_windows()
await self._apply_ev_charging_state()
# Tell the gateway the current read-only state (once on startup, then on every change)
await self._check_read_only_state()
# Send inverter_reset for any inverter not yet reset when not in read-only mode
await self._check_inverter_resets()
# Tell the gateway each inverter's charge/discharge rate cap (once, then on change)
await self._check_rate_caps()
# Publish predbat data (price, timeline) to device display
if self._mqtt_connected and self._auto_configured:
await self._publish_predbat_data()
# Token refresh check
await self._check_token_refresh()
# Re-publish the plan periodically so its embedded timestamp stays fresh
await self._republish_plan_if_stale()
# Mark component as alive on successful housekeeping
self.update_success_timestamp()
except Exception as e:
self.log(f"Warn: GatewayMQTT: housekeeping error: {e}")
return True
async def _mqtt_loop(self):
"""Continuous MQTT listener with automatic reconnection.
Connects to the broker with TLS, subscribes to status and online
topics, and dispatches incoming messages. Reconnects on failure
with exponential backoff.
"""
backoff = 5
max_backoff = 60
while not self.api_stop:
try:
tls_context = ssl.create_default_context()
client_id = f"predbat-{self.gateway_device_id}-{uuid.uuid4().hex[:8]}"
async with aiomqtt.Client(
hostname=self.mqtt_host,
port=self.mqtt_port,
username=self.gateway_device_id,
password=self.mqtt_token,
tls_context=tls_context,
identifier=client_id,
keepalive=60,
) as client:
self._mqtt_client = client
self._mqtt_connected = True
self._first_connection_attempted = True
backoff = 5 # Reset backoff on successful connection
self.log(f"Info: GatewayMQTT: Connected to {self.mqtt_host}:{self.mqtt_port}")
# Subscribe to status and LWT topics
await client.subscribe(self.topic_status, qos=1)
await client.subscribe(self.topic_online, qos=1)
self.log(f"Info: GatewayMQTT: Subscribed to {self.topic_status} and {self.topic_online}")
# Command acks are optional: without them control writes behave as before
with self._command_lock:
self._pending_commands = {}
self._acks_seen = False
self._ack_subscribed = await self._subscribe_acks(client)
async for message in client.messages:
if self.api_stop:
break
await self._handle_message(message)
except asyncio.CancelledError:
self.log("Info: GatewayMQTT: MQTT loop cancelled")
break
except Exception as e:
self._error_count += 1
self.log(f"Warn: GatewayMQTT: MQTT connection error: {e}")
self._mqtt_connected = False
self._mqtt_client = None
self._ack_subscribed = False
self._first_connection_attempted = True
if self.api_stop:
break
# If the broker rejected our credentials (e.g. the MQTT JWT expired),
# refresh the token before reconnecting — otherwise we would retry the
# same rejected token forever and lose all gateway control.
await self._maybe_refresh_on_auth_error(e)
self.log(f"Info: GatewayMQTT: Reconnecting in {backoff}s")
await asyncio.sleep(backoff)
backoff = min(backoff * 2, max_backoff)
self._mqtt_connected = False
self._mqtt_client = None
self._ack_subscribed = False
async def _subscribe_acks(self, client):
"""Subscribe to the hub's per-command acks, tolerating a broker that refuses it.
Returns:
bool: True if the broker granted the subscription.
"""
try:
result = await client.subscribe(self.topic_ack, qos=0)
code = result[0] if result else 0x80
code = int(getattr(code, "value", code))
if code < 0x80:
self.log(f"Info: GatewayMQTT: Subscribed to {self.topic_ack}")
return True
reason = f"refused by broker, code {code}"
except Exception as e:
reason = str(e)
if not self._ack_subscribe_warned:
self._ack_subscribe_warned = True
self.log(f"Warn: GatewayMQTT: Cannot subscribe to command acks ({reason}) - control writes will be re-sent until telemetry confirms them")
return False
async def _handle_message(self, message):
"""Dispatch an incoming MQTT message to the appropriate handler.
Args:
message: An aiomqtt.Message with topic and payload.
"""
topic = str(message.topic)
try:
if topic == self.topic_status:
self._process_telemetry(message.payload)
await self._save_discovery()
elif topic.startswith(getattr(self, "_ack_topic_prefix", "\0")):
self._process_ack(message.payload)
elif topic == self.topic_online:
payload = message.payload.decode("utf-8", errors="replace").strip()
was_online = self._gateway_online
self._gateway_online = payload == "1"
if self._gateway_online != was_online:
self.log(f"Info: GatewayMQTT: Gateway is {'online' if self._gateway_online else 'offline'}")
# A hub that has been away may be on new firmware or have lost its stored
# rate caps, so send them again. Done here, not only in _check_rate_caps():
# a reboot is quicker than the gap between two run() cycles.
self._rate_caps_sent.clear()
self.dashboard_item(
f"binary_sensor.{self.prefix}_gateway_online",
self._gateway_online,
attributes=GATEWAY_ATTRIBUTE_TABLE.get("gateway_online", {}),
app="gateway",
)
except Exception as e:
self._error_count += 1
self.log(f"Warn: GatewayMQTT: Error handling message on {topic}: {e}")
self.log(f"Warn: {traceback.format_exc()}")
def _debug_dump(self, label, message=None, raw=None, message_type=None):
"""Log a protobuf message as readable text when debug logging is enabled.
Pass an already-decoded `message`, or `raw` bytes plus a `message_type` to
decode them. The raw length is logged as a size reference when provided.
Args:
label: Short description of what is being dumped (e.g. "RX telemetry").
message: A decoded protobuf message to render as text (optional).
raw: Optional raw bytes, whose length is logged for size reference.
message_type: Protobuf class used to decode `raw` when `message` is None.
"""
if not self._debug:
return
try:
if message is None and raw is not None and message_type is not None:
message = message_type()
message.ParseFromString(raw)
size = f" ({len(raw)} bytes)" if raw is not None else ""
text = _pb_json_format.MessageToJson(message, always_print_fields_with_no_presence=True, preserving_proto_field_name=True)
self.log(f"Debug: GatewayMQTT: {label}{size}:\n{text}")
except Exception as e:
self.log(f"Warn: GatewayMQTT: failed to dump {label}: {e}")
def _process_telemetry(self, data):
"""Decode telemetry protobuf and inject per-inverter entities.
Args:
data: Raw protobuf bytes from the /status topic.
"""
try:
status = pb.GatewayStatus()
status.ParseFromString(data)
except Exception as e:
self._error_count += 1
self.log(f"Warn: GatewayMQTT: Failed to decode telemetry: {e}")
return
self._debug_dump("RX telemetry", status, raw=data)
if self.gateway_display_only:
# A desk display need not discover an inverter. Do not inject the hub's
# inverter entities or register hardware against the selected provider.
self._last_status = status
self._last_telemetry_time = time.time()
self.update_success_timestamp()
self.api_started = True
return
if len(status.inverters) == 0:
if not (self.gateway_inverter_serial and self._discovery):
return
# With a serial list the discovered inverters are the site whatever the hub reports,
# so a status naming none of them is every one of them unread, not nothing to do
# (#5471). It is not fresh data though, so it does not count towards being alive.
else:
self._last_telemetry_time = time.time()
self.update_success_timestamp()
self._last_status = status
self._update_discovery(status)
self._update_integrated_energy(status)
self._inject_entities(status)
if self._needs_reconfigure(status):
self.automatic_config()
if self._auto_configured:
# The injection above ran before this status's inverters were bound, so one that
# is only published once bound has no entities yet. Publish those before the API
# is declared started below.
for inv in status.inverters:
if not inv.primary and self._is_published(inv):
self._publish_inverter(inv)
# The integration above ran on the slots as they were before this status changed them
self._update_integrated_energy(status, rebase=True)
self._inject_unreported_inverters(status)
self._inject_shared_site_metering(status)
self._check_configured_inverters(status)
# Declare the API started only once auto-config has wired up the inverter args.
# ComponentManager.start() polls api_started from the main thread while this
# handler runs in the component's own thread, so setting it any earlier releases
# PredBat startup with num_inverters / soc_percent unset. When auto-config cannot
# complete (e.g. a serial filter matching nothing) the run() startup path still
# releases the component after its timeout.
if self._auto_configured and not self.api_started:
self.api_started = True
self.log("Info: GatewayMQTT: First telemetry received and auto-config complete, API started")
def _is_bound_target(self, inv):
"""Whether automatic_config bound PredBat's args to this inverter's suffix.
``_suffix_to_serial`` is populated by ``automatic_config`` with exactly the
control-target suffixes, so it is the authoritative record of which entities
PredBat reads. Empty before the first auto-config, in which case no unit is
bound yet and the primary-only path applies.
Args:
inv: A ``predbat_InverterEntry`` from the gateway status.
Returns:
bool: True if this unit's entities are read by PredBat.
"""
return _serial_suffix(inv.serial) in self._suffix_to_serial
def _inject_entities(self, status):
"""Inject inverter entities into PredBat state cache.
Maps GatewayStatus fields to PredBat entity format using HA-style
entity naming: {type}.{prefix}_gateway_{suffix}_{attribute}
"""
device_id = status.device_id
firmware = status.firmware
# Read-only as the gateway actually enforces it. Absent on firmware predating
# the field, which is UNKNOWN and not False — publishing False there would claim
# the gateway is controllable when nothing has reported either way.
read_only = status.read_only if status.HasField("read_only") else None
self.dashboard_item(
f"binary_sensor.{self.prefix}_gateway_online",
True,
attributes={**GATEWAY_ATTRIBUTE_TABLE.get("gateway_online", {}), "device_id": device_id, "firmware": firmware, "read_only": read_only},
app="gateway",
)
if read_only is not None:
self.dashboard_item(
f"binary_sensor.{self.prefix}_gateway_read_only",
read_only,
attributes=GATEWAY_ATTRIBUTE_TABLE.get("gateway_read_only", {}),
app="gateway",
)
self._check_read_only_mismatch(read_only)
# Inverter time from gateway timestamp — write it under the suffix PredBat