forked from wills106/homeassistant-solax-modbus
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy path__init__.py
More file actions
2587 lines (2366 loc) · 126 KB
/
Copy path__init__.py
File metadata and controls
2587 lines (2366 loc) · 126 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
"""The SolaX Modbus Integration."""
import asyncio
# import importlib.util, sys
import importlib
import json
import logging
import time as _mtime
from dataclasses import dataclass, replace
from datetime import timedelta
from types import ModuleType, SimpleNamespace
from typing import Any, cast
from weakref import ref as WeakRef
import homeassistant.helpers.config_validation as cv
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_HOST,
CONF_NAME,
CONF_PORT,
EVENT_HOMEASSISTANT_STARTED,
EVENT_HOMEASSISTANT_STOP,
PERCENTAGE,
Platform,
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfFrequency,
UnitOfPower,
UnitOfTemperature,
UnitOfTime,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.event import async_track_time_interval
from pymodbus.client import AsyncModbusSerialClient, AsyncModbusTcpClient
from pymodbus.exceptions import ConnectionException, ModbusException, ModbusIOException
from pymodbus.framer import FramerType
from .const import (
BUTTONREPEAT_FIRST as BUTTONREPEAT_FIRST,
)
from .const import (
BUTTONREPEAT_LOOP,
BUTTONREPEAT_POST,
CONF_BAUDRATE,
CONF_CORE_HUB,
CONF_DEBUG_SETTINGS,
CONF_INTERFACE,
CONF_INVERTER_NAME_SUFFIX,
CONF_INVERTER_POWER_KW,
CONF_MODBUS_ADDR,
CONF_PLUGIN,
CONF_SERIAL_PORT,
CONF_TCP_TYPE,
CONF_TIME_OUT,
DEFAULT_BAUDRATE,
DEFAULT_INVERTER_POWER_KW,
DEFAULT_MODBUS_ADDR,
DEFAULT_PORT,
DEFAULT_SERIAL_PORT,
DEFAULT_TCP_TYPE,
DEFAULT_TIME_OUT,
DOMAIN,
INVERTER_IDENT,
# PLUGIN_PATH,
REG_HOLDING,
REG_INPUT,
REGISTER_F32,
REGISTER_S16,
REGISTER_S32,
REGISTER_STR,
REGISTER_U8H,
REGISTER_U8L,
REGISTER_U16,
REGISTER_U32,
REGISTER_ULSB16MSB16,
REGISTER_WORDS,
SCAN_GROUP_AUTO,
SCAN_GROUP_DEFAULT,
SLEEPMODE_LASTAWAKE,
WRITE_MULTI_MODBUS,
WRITE_SINGLE_MODBUS,
)
from .const import (
CONF_READ_DCB as CONF_READ_DCB,
)
from .const import (
CONF_READ_EPS as CONF_READ_EPS,
)
from .const import (
DEFAULT_INTERFACE as DEFAULT_INTERFACE,
)
from .const import (
DEFAULT_INVERTER_NAME_SUFFIX as DEFAULT_INVERTER_NAME_SUFFIX,
)
from .const import (
DEFAULT_NAME as DEFAULT_NAME,
)
from .const import (
DEFAULT_PLUGIN as DEFAULT_PLUGIN,
)
from .const import (
DEFAULT_READ_DCB as DEFAULT_READ_DCB,
)
from .const import (
DEFAULT_READ_EPS as DEFAULT_READ_EPS,
)
from .const import (
DEFAULT_SCAN_INTERVAL as DEFAULT_SCAN_INTERVAL,
)
from .const import (
SCAN_GROUP_MEDIUM as SCAN_GROUP_MEDIUM,
)
from .const import (
WRITE_MULTISINGLE_MODBUS as WRITE_MULTISINGLE_MODBUS,
)
from .pymodbus_compat import ADDR_KW, DataType, convert_from_registers, convert_to_registers, pymodbus_version_info
from .sensor import SolaXModbusSensor
RETRIES = 1 # was 6 then 0, which worked also, but 1 is probably the safe choice
INVALID_START = 99999
VERBOSE_CYCLES = 20
COMM_HISTORY_LIMIT = 100
COMM_BLOCK_FAILURE_THRESHOLD = 3
COMM_BLOCK_FAILURE_WINDOW = 600
COMM_RECOVERY_INTERVAL = 300
INFLIGHT_CANCEL_TIMEOUT = 2.0
CONNECT_RETRY_DELAY = 1.0
try:
from homeassistant.components.modbus import ModbusHub as CoreModbusHub # type: ignore[attr-defined]
from homeassistant.components.modbus import get_hub as get_core_hub
except ImportError:
def get_core_hub(hass: HomeAssistant, name: str) -> None: # type: ignore[misc]
return None
class CoreModbusHub: # type: ignore[no-redef] # placeholder dummy
pass
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.BUTTON, Platform.NUMBER, Platform.SELECT, Platform.SENSOR, Platform.SWITCH, Platform.TIME]
# CONFIG_SCHEMA allows YAML configuration ONLY for debug_settings (DEVELOPMENT/TESTING/DEBUGGING ONLY)
# All other configuration must be done via config flow (UI)
CONFIG_SCHEMA = vol.Schema(
{
vol.Optional(DOMAIN): vol.Schema(
{
vol.Optional(CONF_DEBUG_SETTINGS): vol.Schema(
{str: vol.Schema({str: cv.boolean})} # Inverter name -> {setting_name: bool}
)
},
extra=vol.ALLOW_EXTRA, # Allow extra keys but they won't be processed
)
},
extra=vol.ALLOW_EXTRA,
)
def empty_hub_interval_group_lambda() -> SimpleNamespace:
return SimpleNamespace(
interval=0,
unsub_interval_method=None,
device_groups={},
poll_lock=asyncio.Lock(),
pending_rerun=False,
)
def empty_hub_device_group_lambda() -> SimpleNamespace:
return SimpleNamespace(
sensors=[],
inputBlocks={},
holdingBlocks={},
readPreparation=None, # function to call before read group
readFollowUp=None, # function to call after read group
)
def should_register_be_loaded(hass: HomeAssistant, hub: Any, descriptor: Any) -> bool:
"""
Check if an entity is enabled in the entity registry, checking across multiple platforms.
"""
if descriptor.internal:
_LOGGER.debug(f"{hub.name}: should be loaded: entity with key {descriptor.key} is internal, returning True.")
return True
unique_id = f"{hub._name}_{descriptor.key}"
unique_id_alt = f"{hub._name}.{descriptor.key}" # dont knnow why
platforms = (Platform.SENSOR, Platform.SELECT, Platform.NUMBER, Platform.SWITCH, Platform.BUTTON, Platform.TIME)
registry = er.async_get(hass)
entity_found = False
# First, check if there is an existing enabled entity in the registry for this unique_id.
for platform in platforms:
entity_id = registry.async_get_entity_id(platform, DOMAIN, unique_id)
if entity_id:
_LOGGER.debug(f"{hub.name}: should be loaded: entity_id for {unique_id} on platform {platform} is now {entity_id}")
else:
entity_id = registry.async_get_entity_id(platform, DOMAIN, unique_id_alt)
_LOGGER.debug(f"{hub.name}: should be loaded: entity_id for alt {unique_id_alt} on platform {platform} is now {entity_id}")
if entity_id:
entity_found = True
entity_entry = registry.async_get(entity_id)
if entity_entry and not entity_entry.disabled:
_LOGGER.debug(f"{hub.name}: should be loaded: Entity {entity_id} is enabled, returning True.")
return True # Found an enabled entity, no need to check further
# If we get here, no enabled entity was found across all platforms.
if entity_found:
# At least one entity exists for this unique_id, but all are disabled. Respect the user's choice.
_LOGGER.debug(f"{hub.name}: should be loaded: entity with unique_id {unique_id} was found but is disabled across all relevant platforms.")
return False
else:
# No entity exists for this unique_id on any platform. Treat it as a new entity.
_LOGGER.debug(f"{hub.name}: should be loaded: entity with unique_id {unique_id} not found in entity registry, checking defaults ")
if descriptor.entity_registry_enabled_default:
return True
# check the other platforms descriptors
d = hub.selectEntities.get(descriptor.key)
if d and d.entity_registry_enabled_default:
return True
d = hub.numberEntities.get(descriptor.key)
if d and d.entity_registry_enabled_default:
return True
d = hub.switchEntities.get(descriptor.key)
if d and d.entity_registry_enabled_default:
return True
d = hub.timeEntities.get(descriptor.key)
if d and d.entity_registry_enabled_default:
return True
_LOGGER.debug(
f"{hub.name}: should be loaded: entity_default with unique_id {unique_id} was found but is disabled across all relevant platforms."
)
return False
async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Update listener, called when the config entry options are changed."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
"""Set up the SolaX modbus component."""
hass.data[DOMAIN] = {}
# Extract debug_settings from YAML configuration (DEVELOPMENT/TESTING/DEBUGGING ONLY)
# Store in hass.data so debug.py can access it
yaml_config = config.get(DOMAIN, {})
debug_settings = yaml_config.get(CONF_DEBUG_SETTINGS)
if debug_settings:
hass.data[DOMAIN]["_debug_settings"] = debug_settings
else:
hass.data[DOMAIN]["_debug_settings"] = {}
async def _stop_hubs_on_homeassistant_stop(event: Any) -> None:
"""Stop active hubs before HA reaches final task cancellation."""
domain_data = hass.data.get(DOMAIN, {})
for name, rec in list(domain_data.items()):
if not isinstance(rec, dict):
continue
hub = rec.get("hub")
if hub:
_LOGGER.debug(f"{name}: Home Assistant stop event - stopping hub")
try:
await hub.async_stop()
except Exception as ex:
_LOGGER.warning(f"{name}: error during Home Assistant stop: {ex}")
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_hubs_on_homeassistant_stop)
# Register helper services to force-stop hubs
async def _svc_stop_all(call: Any) -> None:
"""Force-stop all SolaX hubs (kills timers/tasks/sockets)."""
domain_data = hass.data.get(DOMAIN, {})
for name, rec in list(domain_data.items()):
hub = rec.get("hub")
if hub:
_LOGGER.warning(f"{name}: stop_all service – stopping hub")
try:
await hub.async_stop()
except Exception as ex:
_LOGGER.warning(f"{name}: stop_all service – error during hub stop: {ex}")
async def _svc_stop_hub(call: Any) -> None:
"""Force-stop a single hub by name."""
name = call.data.get("name")
if not name:
_LOGGER.warning("stop_hub service – missing 'name'")
return
domain_data = hass.data.get(DOMAIN, {})
rec = domain_data.get(name)
hub = rec.get("hub") if rec else None
if hub:
_LOGGER.warning(f"{name}: stop_hub service – stopping hub")
try:
await hub.async_stop()
except Exception as ex:
_LOGGER.warning(f"{name}: stop_hub service – error during hub stop: {ex}")
# also remove from hass.data to avoid zombie references
if rec:
domain_data.pop(name, None)
hass.services.async_register(DOMAIN, "stop_all", _svc_stop_all)
hass.services.async_register(DOMAIN, "stop_hub", _svc_stop_hub)
# _LOGGER.debug("solax data %d", hass.data)
return True
# Example migration function
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Migrate old entry."""
_LOGGER.debug("Migrating from version %s", config_entry.version)
if config_entry.version == 1:
new = {**config_entry.options}
# TODO: modify Config Entry data
config_entry.version = 2
hass.config_entries.async_update_entry(config_entry, data=new)
_LOGGER.info("Migration to version %s successful", config_entry.version)
return True
def _load_plugin(plugin_name: str) -> ModuleType:
_LOGGER.info("trying to load plugin - plugin_name: %s", plugin_name)
plugin = importlib.import_module(f".plugin_{plugin_name}", "custom_components.solax_modbus")
if not plugin:
_LOGGER.error("Could not import plugin with name: %s", plugin_name)
return plugin
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a SolaX modbus."""
_LOGGER.debug(f"setup config entries - data: {entry.data}, options: {entry.options}")
# Ensure DOMAIN dict exists (needed for reload support)
# async_setup() only runs once at HA startup, but async_setup_entry()
# runs for each config entry AND during reloads, so we must ensure
# the domain dictionary exists before using it
if DOMAIN not in hass.data:
hass.data[DOMAIN] = {}
config = entry.options
# Stop a previously running hub with the same name before creating a new one
old_name = config.get(CONF_NAME)
try:
existing = hass.data.get(DOMAIN, {}).get(old_name)
except Exception:
existing = None
if existing and (old_hub := existing.get("hub")):
_LOGGER.info(f"{old_name}: stopping previous hub and unloading platforms for reload")
try:
await old_hub.async_stop()
except Exception as ex:
_LOGGER.warning(f"{old_name}: error while stopping previous hub: {ex}")
# Unload platforms so they can be reloaded with the new hub
# This is necessary for reload_config_entry to work properly
if old_hub._platforms_forwarded:
try:
_LOGGER.debug(f"{old_name}: unloading platforms for reload")
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
_LOGGER.debug(f"{old_name}: platforms unloaded successfully")
else:
_LOGGER.warning(f"{old_name}: platform unload returned False")
except Exception as ex:
_LOGGER.warning(f"{old_name}: error unloading platforms during reload: {ex}")
hass.data.get(DOMAIN, {}).pop(old_name, None)
plugin_name = config[CONF_PLUGIN]
# convert old style to new style plugin name here - Remove later after a breaking upgrade
if plugin_name.startswith("custom_components") or plugin_name.startswith("/config") or plugin_name.startswith("plugin_"):
new = {**config}
plugin_name = plugin_name.split("plugin_", 1)[1][:-3]
_LOGGER.warning(f"converting old style plugin name {config[CONF_PLUGIN]} to new style short name {plugin_name}")
new[CONF_PLUGIN] = plugin_name
hass.config_entries.async_update_entry(entry, options=new)
# end of conversion
# ================== dynamically load desired plugin =======================================================
plugin = await hass.async_add_executor_job(_load_plugin, plugin_name)
# ====================== end of dynamic load ==============================================================
hub: SolaXModbusHub
if config.get(CONF_INTERFACE, None) == "core":
hub = SolaXCoreModbusHub(
hass,
plugin,
entry,
)
else:
hub = SolaXModbusHub(
hass,
plugin,
entry,
)
try:
from .energy_dashboard import register_energy_dashboard_switch_provider
register_energy_dashboard_switch_provider(hass)
except Exception as ex:
_LOGGER.debug(f"{hub.name}: Energy Dashboard switch provider registration failed: {ex}")
"""Register the hub."""
hass.data[DOMAIN][hub._name] = {
"hub": hub,
}
# Tests on some systems have shown that establishing the Modbus connection
# can occasionally lead to errors if Home Assistant is not fully loaded.
if hass.is_running:
# Start init in background so it can be cancelled on unload
hub._init_task = hass.loop.create_task(hub.async_init())
else:
# Defer until HA is started, but still capture the task handle for cancellation
async def _deferred_init(event: Any) -> None:
if getattr(hub, "_stopping", False):
return
hub._init_task = hass.loop.create_task(hub.async_init())
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _deferred_init)
entry.async_on_unload(entry.add_update_listener(config_entry_update_listener))
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload SolaX modbus entry and tear down transports cleanly."""
name = entry.options.get("name")
_LOGGER.debug(f"async_unload_entry called for {name} – state={entry.state}")
hub = hass.data.get(DOMAIN, {}).get(name, {}).get("hub")
if hub:
try:
await hub.async_stop()
except Exception as ex:
_LOGGER.warning(f"{name}: error during hub stop: {ex}")
# Unload platforms - this must succeed for reload to work properly
# Always try to unload regardless of entry state - during reload, state might not be LOADED
unload_ok = True
try:
_LOGGER.debug(f"{name}: attempting to unload platforms (state={entry.state})")
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
_LOGGER.debug(f"{name}: platforms unloaded successfully")
else:
_LOGGER.error(f"{name}: platform unload returned False")
except Exception as ex:
_LOGGER.error(f"{name}: error during platform unload: {ex}")
unload_ok = False
# Ensure removal from hass.data
try:
hass.data.get(DOMAIN, {}).pop(name, None)
except Exception as ex:
_LOGGER.warning(f"{name}: error removing from hass.data: {ex}")
return unload_ok
def defaultIsAwake(datadict: dict[str, Any]) -> bool:
return True
def Gen4Timestring(numb: int) -> str:
h = numb % 256
m = numb >> 8
return f"{h:02d}:{m:02d}"
@dataclass
class block:
start: int | None = None # start address of the block
end: int | None = None # end address of the block
# order16: int = None # byte endian for 16bit registers
# order32: int = None # word endian for 32bit registers
descriptions: Any = None
regs: Any = None # sorted list of registers used in this block
class SolaXModbusHub:
"""Thread safe wrapper class for pymodbus."""
def __init__(
self,
hass: HomeAssistant,
plugin: ModuleType,
entry: ConfigEntry,
) -> None:
config = entry.options
name = config[CONF_NAME]
host = config.get(CONF_HOST, None)
port = config.get(CONF_PORT, DEFAULT_PORT)
tcp_type = config.get(CONF_TCP_TYPE, DEFAULT_TCP_TYPE)
modbus_addr = config.get(CONF_MODBUS_ADDR, DEFAULT_MODBUS_ADDR)
if modbus_addr is None:
modbus_addr = DEFAULT_MODBUS_ADDR
_LOGGER.warning(f"{name} integration may need to be reconfigured for this version; using default Solax modbus_address {modbus_addr}")
interface = config.get(CONF_INTERFACE, None)
if not interface: # core modbus parameter name was read_serial, this block can be removed later
if config.get("read_serial", False):
interface = "serial"
else:
interface = "tcp"
serial_port = config.get(CONF_SERIAL_PORT, DEFAULT_SERIAL_PORT)
baudrate = int(config.get(CONF_BAUDRATE, DEFAULT_BAUDRATE))
time_out = int(config.get(CONF_TIME_OUT, DEFAULT_TIME_OUT))
_LOGGER.debug(f"Setup {DOMAIN}.{name}")
_LOGGER.debug(f"solax serial port {serial_port} interface {interface}")
"""Initialize the Modbus hub."""
_LOGGER.debug(f"solax modbushub creation with interface {interface} baudrate (only for serial): {baudrate}")
self._hass = hass
# explicit init for stop flag
self._stopping = False
self._client: AsyncModbusSerialClient | AsyncModbusTcpClient | SimpleNamespace
if interface == "serial":
self._client = AsyncModbusSerialClient(
port=serial_port,
baudrate=baudrate,
parity="N",
stopbits=1,
bytesize=8,
timeout=time_out,
retries=RETRIES,
)
elif interface == "tcp":
if tcp_type == "rtu":
self._client = AsyncModbusTcpClient(host=host, port=port, timeout=time_out, framer=FramerType.RTU, retries=RETRIES)
elif tcp_type == "ascii":
self._client = AsyncModbusTcpClient(host=host, port=port, timeout=time_out, framer=FramerType.ASCII, retries=RETRIES)
else:
self._client = AsyncModbusTcpClient(host=host, port=port, timeout=time_out, retries=RETRIES)
elif interface == "core":
# Core-hub variant uses Home Assistant's Modbus hub; use harmless dummy client
self._client = SimpleNamespace(connected=False, comm_params=SimpleNamespace(host="", port=""))
else:
# Fallback dummy client for unrecognized interface types
self._client = SimpleNamespace(connected=False, comm_params=SimpleNamespace(host="", port=""))
self._lock = asyncio.Lock()
self._connect_lock = asyncio.Lock()
self._next_connect_attempt = 0.0
self._name: str = name
# following call will modify and extend client in case old modbus API needs to be used
_LOGGER.debug(f"{name}: using pymodbus version {pymodbus_version_info()}")
self.inverterNameSuffix = config.get(CONF_INVERTER_NAME_SUFFIX)
self.inverterPowerKw = config.get(CONF_INVERTER_POWER_KW, DEFAULT_INVERTER_POWER_KW)
self._modbus_addr = modbus_addr
self._seriesnumber = "still unknown"
self.interface = interface
self.read_serial_port = serial_port
self._baudrate = int(baudrate)
self._time_out = int(time_out)
self.groups: dict[Any, Any] = {} # group info, below
self.data: dict[str, Any] = {"_repeatUntil": {}} # _repeatuntil contains button autorepeat expiry times
self.tmpdata: dict[Any, Any] = {} # for WRITE_DATA_LOCAL entities with corresponding prevent_update number/sensor
self.tmpdata_expiry: dict[Any, Any] = {} # expiry timestamps for tempdata
self.cyclecount: int = 0 # temporary - remove later
self.slowdown: int = 1 # slow down factor when modbus is not responding: 1 : no slowdown, 10: ignore 9 out of 10 cycles
self.computedSensors: dict[Any, Any] = {}
self.computedEntities: dict[Any, Any] = {} # buttons and selects with value_function for autorepeat
self.computedSwitches: dict[Any, Any] = {}
self.sensorEntities: dict[Any, Any] = {} # all sensor entities, indexed by key
self.numberEntities: dict[Any, Any] = {} # all number entities, indexed by key
self.selectEntities: dict[Any, Any] = {}
self.switchEntities: dict[Any, Any] = {}
self.timeEntities: dict[Any, Any] = {}
self.entity_dependencies: dict[str, list[str]] = {} # Maps a sensor key to a list of data control keys that use the sensor as data source
# self.preventSensors = {} # sensors with prevent_update = True
self.writeLocals: dict[Any, Any] = {} # key to description lookup dict for write_method = WRITE_DATA_LOCAL entities
self.sleepzero: list[str] = [] # sensors that will be set to zero in sleepmode
self.sleepnone: list[str] = [] # sensors that will be cleared in sleepmode
self.writequeue: dict[Any, Any] = {} # queue requests when inverter is in sleep mode
_LOGGER.debug(f"{self.name}: ready to call plugin to determine inverter type")
self.plugin = plugin.plugin_instance # getPlugin(name).plugin_instance
self.plugin_module = plugin # Store plugin module for accessing module-level functions
self._validate_register_func = getattr(plugin, "validate_register_data", None) # Cache function reference
self.wakeupButton: Any = None
self._invertertype: int | None = None
self.modbus_protocol_version: int | None = None
self.localsUpdated: bool = False
self.localsLoaded: bool = False
self.config: Any = config # MappingProxyType from entry.options
self.entry: ConfigEntry = entry
self.device_info: DeviceInfo | None = None
self.blocks_changed: bool = False
self.initial_groups: dict[Any, Any] = {} # as returned by the sensor setup - holdingRegs and inputRegs should not change
# Track in-flight I/O tasks for fast cancellation on stop
self._inflight_tasks: set[Any] = set()
# Runtime bad register handling. bad_regs are temporarily quarantined
# entity base-addresses that are excluded from normal polling.
self.bad_regs: dict[str, set[int]] = {"holding": set(), "input": set()}
self.bisect_max_depth = 10 # safety cap to avoid pathological recursion
self._runtime_bisect_tasks: dict[str, asyncio.Task[Any]] = {}
self._quarantine_recheck_task: asyncio.Task[Any] | None = None
self._comm_block_failures: dict[str, list[float]] = {}
self._comm_last_block_success_time: float | None = None
self._comm_last_block_failure_time: float | None = None
self._comm_recent_results: list[bool] = []
self._comm_poll_durations: list[int] = []
self._comm_last_error: str | None = None
self._comm_last_error_time: str | None = None
self._comm_last_quarantined_register: str | None = None
self._comm_last_recovered_register: str | None = None
self._comm_overrun_count = 0
self._comm_recovery_active = False
# Polling is no longer blocked by a startup bisect. Bad registers are
# found at runtime and rechecked periodically.
self._probe_ready = asyncio.Event()
self._probe_ready.set()
self._initial_refresh_task: Any = None
self._initial_refresh_active: bool = False
self._initial_refresh_done: bool = False
# Deferred setup state
self._platforms_forwarded = False
self._deferred_setup_task: Any = None
# _LOGGER.debug("solax modbushub done %s", self.__dict__)
def _start_initial_refresh_if_needed(self) -> None:
"""Start the one-shot initial refresh after platforms are available."""
if self._initial_refresh_done or self._initial_refresh_task is not None:
return
if getattr(self, "_stopping", False):
return
if not self._platforms_forwarded:
return
self._initial_refresh_task = self._hass.loop.create_task(self._run_initial_refresh_when_ready())
async def async_init(self, *args: Any) -> None: # noqa: D102
import asyncio
import time as _t
self._init_task: Any = asyncio.current_task()
# Exit early if teardown requested
if getattr(self, "_stopping", False):
return
# Try to detect inverter type, but do not block setup indefinitely.
# We allow up to ~15s for initial detection; afterwards we proceed with a generic setup
# so that the integration is usable even with no device connected.
deadline = _t.monotonic() + 15.0
attempts = 0
while self._invertertype in (None, 0) and not getattr(self, "_stopping", False):
try:
await self.async_connect()
await self._check_connection()
if getattr(self, "_stopping", False):
return
# Attempt type detection via plugin (may return 0/None if unreachable)
self._invertertype = await self.plugin.async_determineInverterType(self, self.config)
attempts += 1
if self._invertertype not in (None, 0):
break
except Exception as ex:
_LOGGER.debug(f"{self._name}: inverter type detect attempt failed: {ex}")
attempts += 1
# Timeout reached → proceed to deferred setup if still not detected
if _t.monotonic() >= deadline:
break
# Small paced wait to avoid tight loop; keep abortable while unloading
for _ in range(100):
if getattr(self, "_stopping", False):
return
await asyncio.sleep(0.1)
# If we reach here with no inverter detected, start deferred detection and return without forwarding platforms
if self._invertertype in (None, 0):
_LOGGER.debug(f"{self._name}: no inverter detected during initial window – deferring setup until device is online")
if not getattr(self, "_stopping", False):
self._deferred_setup_task = self._hass.loop.create_task(self._deferred_setup_loop())
return
# Prepare device_info (inverter detected during initial window)
plugin_name = self.plugin.plugin_name
if self.inverterNameSuffix is not None and self.inverterNameSuffix != "":
plugin_name = plugin_name + " " + self.inverterNameSuffix
self.device_info = DeviceInfo(
identifiers=cast(set[tuple[str, str]], {(DOMAIN, self._name, INVERTER_IDENT)}),
manufacturer=self.plugin.plugin_manufacturer,
model=getattr(self.plugin, "inverter_model", None),
name=plugin_name,
serial_number=self.seriesnumber,
sw_version=self.plugin.getSoftwareVersion(self.data),
hw_version=self.plugin.getHardwareVersion(self.data),
)
if getattr(self, "_stopping", False):
_LOGGER.info(f"{self._name}: init aborted – stopping during init")
return
# Forward platforms for this config entry
# Platforms should be unloaded before reload, so this should always succeed
if not self._platforms_forwarded:
try:
await self._hass.config_entries.async_forward_entry_setups(self.entry, PLATFORMS)
self._platforms_forwarded = True
_LOGGER.debug(f"{self._name}: platforms forwarded successfully")
self._start_initial_refresh_if_needed()
except ValueError as ex:
# If platforms are already set up, log warning but continue
# This shouldn't happen if unload worked properly, but handle gracefully
_LOGGER.warning(f"{self._name}: platforms already forwarded - reload may not work correctly: {ex}")
self._platforms_forwarded = True
self._start_initial_refresh_if_needed()
else:
_LOGGER.debug(f"{self._name}: platforms already forwarded on this hub instance, skipping")
self._start_initial_refresh_if_needed()
self._init_task = None
async def _deferred_setup_loop(self, interval: int = 30) -> None:
"""Keep trying to detect inverter type and forward platforms once online."""
import asyncio
while (not getattr(self, "_stopping", False)) and (not self._platforms_forwarded):
try:
await self.async_connect()
await self._check_connection()
if getattr(self, "_stopping", False):
return
inv = await self.plugin.async_determineInverterType(self, self.config)
if inv not in (None, 0):
self._invertertype = inv
_LOGGER.debug(f"{self._name}: inverter detected during deferred setup (type={inv}) – forwarding platforms")
# Prepare/refresh device_info in case it wasn't set
plugin_name = self.plugin.plugin_name
if self.inverterNameSuffix:
plugin_name = plugin_name + " " + self.inverterNameSuffix
self.device_info = DeviceInfo(
identifiers=cast(set[tuple[str, str]], {(DOMAIN, self._name, INVERTER_IDENT)}),
manufacturer=self.plugin.plugin_manufacturer,
model=getattr(self.plugin, "inverter_model", None),
name=plugin_name,
serial_number=self.seriesnumber,
sw_version=self.plugin.getSoftwareVersion(self.data),
hw_version=self.plugin.getHardwareVersion(self.data),
)
if getattr(self, "_stopping", False):
return
await self._hass.config_entries.async_forward_entry_setups(self.entry, PLATFORMS)
self._platforms_forwarded = True
return
else:
_LOGGER.debug(f"{self._name}: deferred setup – inverter still not responding, will retry in {interval}s")
except Exception as ex:
_LOGGER.debug(f"{self._name}: deferred setup iteration failed: {ex}")
# Wait and try again
for _ in range(interval * 10): # sleep in 0.1s steps to remain abortable
if getattr(self, "_stopping", False):
return
await asyncio.sleep(0.1)
# save and load local data entity values to make them persistent
DATAFORMAT_VERSION = 1
def saveLocalData(self) -> None:
tosave: dict[str, Any] = {"_version": self.DATAFORMAT_VERSION}
for desc in self.writeLocals:
tosave[desc] = self.data.get(desc)
with open(self._hass.config.path(f"{self.name}_data.json"), "w") as fp:
json.dump(tosave, fp)
self.localsUpdated = False
_LOGGER.debug(f"saved modified persistent date: {tosave}")
def loadLocalData(self) -> None:
try:
fp = open(self._hass.config.path(f"{self.name}_data.json"))
except Exception:
if self.cyclecount > 5:
_LOGGER.debug("no local data file found after 5 tries - is this a first time run? or didn't you modify any DATA_LOCAL entity?")
self.localsLoaded = True # retry a couple of polling cycles - then assume non-existent"
return
try:
loaded = json.load(fp)
except Exception:
_LOGGER.debug("Local data file not readable. Resetting to empty")
fp.close()
self.saveLocalData()
return
else:
if loaded.get("_version") == self.DATAFORMAT_VERSION:
for desc in self.writeLocals:
val = loaded.get(desc)
if val is not None:
self.data[desc] = val
else:
self.data[desc] = self.writeLocals[desc].initvalue # first time initialisation
else:
_LOGGER.warning(f"local persistent data lost - please reinitialize {self.writeLocals.keys()}")
fp.close()
self.localsLoaded = True
self.plugin.localDataCallback(self)
try:
self._hass.loop.call_soon_threadsafe(
self._hass.bus.async_fire,
"solax_modbus_local_data_loaded",
{"hub_name": self._name},
)
except Exception as ex:
_LOGGER.debug(f"{self._name}: failed to fire local data event: {ex}")
# end of save and load section
def scan_group(self, sensor: Any) -> int: # seems to be called for non-sensor entities also - strange
# scan group
g = getattr(sensor.entity_description, "scan_group", None)
if not g:
regtype = getattr(sensor.entity_description, "register_type", None)
if regtype == REG_HOLDING:
g = self.plugin.default_holding_scangroup
elif regtype == REG_INPUT:
g = self.plugin.default_input_scangroup
else:
_LOGGER.debug(f"{self._name}: default scan_group for {sensor.entity_description.key} returned {g} - {SCAN_GROUP_DEFAULT}")
g = SCAN_GROUP_DEFAULT # should not occur
if g == SCAN_GROUP_AUTO:
unit = getattr(sensor.entity_description, "native_unit_of_measurement", None)
if unit in ( # slow changing values
UnitOfEnergy.WATT_HOUR,
UnitOfEnergy.KILO_WATT_HOUR,
UnitOfFrequency.HERTZ,
UnitOfTemperature.CELSIUS,
UnitOfTemperature.FAHRENHEIT,
UnitOfTemperature.KELVIN,
UnitOfTime.HOURS,
):
g = self.plugin.auto_slow_scangroup
else:
g = self.plugin.auto_default_scangroup
# scan interval
g = self.config.get(g, None)
# when declared but not present in config, use default; this MUST exist
if g is None:
_LOGGER.warning(
f"{self._name}: Fast or Medium scan groups do not seem to exist in config: {g} using default {self.config[SCAN_GROUP_DEFAULT]}"
)
g = self.config[SCAN_GROUP_DEFAULT]
else:
_LOGGER.debug(f"{self._name}: returning scan_group interval {g} for {sensor.entity_description.key}")
return int(g)
def device_group_key(self, device_info: DeviceInfo) -> str:
"""Extract device group key from device_info identifiers.
CRITICAL: This is called during sensor setup for every entity.
The device_info parameter should NEVER be None.
"""
key = ""
# DEFENSIVE: Check if device_info is None (should never happen)
if device_info is None:
_LOGGER.error(f"{self._name}: device_group_key called with None device_info! This is a BUG - device_info should never be None here.") # type: ignore[unreachable]
return ""
# DEFENSIVE: Check if it's a dict-like object
if not isinstance(device_info, dict):
_LOGGER.error(f"{self._name}: device_group_key called with non-dict device_info! type={type(device_info)}, value={device_info}") # type: ignore[unreachable]
return ""
# DEFENSIVE: Check if "identifiers" key exists
if "identifiers" not in device_info:
_LOGGER.error(
f"{self._name}: device_group_key called with device_info missing 'identifiers' key! "
f"keys={list(device_info.keys())}, device_info={device_info}"
)
return ""
identifiers = device_info["identifiers"]
# DEFENSIVE: Check if identifiers is None
if identifiers is None:
_LOGGER.error(f"{self._name}: device_group_key got None for device_info['identifiers']! device_info={device_info}") # type: ignore[unreachable]
return ""
# DEFENSIVE: Check if identifiers is iterable
try:
iter(identifiers)
except TypeError:
_LOGGER.error(f"{self._name}: device_group_key got non-iterable identifiers! type={type(identifiers)}, value={identifiers}")
return ""
for identifier in identifiers:
identifier_tuple = cast(tuple[str, ...], identifier)
if identifier_tuple[0] != DOMAIN:
continue
key = identifier_tuple[1] + "_" + identifier_tuple[2]
return key
# following function is the added_to_hass callback for sensors, numbers and selects
@callback
async def async_add_solax_modbus_sensor(self, sensor: SolaXModbusSensor) -> None:
"""Listen for data updates."""
# attention, this function is not only called for sensors also for number, select
# This is the first sensor, set up interval.
interval = self.scan_group(sensor)
interval_group = self.groups.setdefault(interval, empty_hub_interval_group_lambda())
if not interval_group.device_groups:
interval_group.interval = interval
async def _refresh(_now: Any = None) -> None:
secs = interval_group.interval
self.cyclecount += 1
cycle_id = self.cyclecount
_LOGGER.debug(f"{self._name}: [{secs}s] poll started – cycle #{cycle_id}")
# If a previous cycle is still running, mark a catch-up and return quickly.
if interval_group.poll_lock.locked():
interval_group.pending_rerun = True
_LOGGER.debug(f"{self._name}: [{secs}s] overrun – previous poll still running; scheduling immediate catch-up after it finishes")
return
# Run cycles back-to-back if a tick was missed while running (catch-up mode)
while True:
start = _mtime.monotonic()
async with interval_group.poll_lock:
agg_res, updated_sensors = await self.async_refresh_modbus_data(interval_group, _now, cycle_id=cycle_id)
elapsed = _mtime.monotonic() - start
_LOGGER.debug(
f"{self._name}: [{secs}s] poll finished – cycle #{cycle_id}, "
f"duration={int(elapsed * 1000)} ms, ok={agg_res}, "
f"sensors={updated_sensors}, slowdown={self.slowdown}"
)
self._record_poll_cycle(agg_res, elapsed, interval_group.interval or secs)
# If the configured interval is shorter than the actual run time, inform once per cycle
if elapsed >= (interval_group.interval or 0):
_LOGGER.debug(
f"{self._name}: [{secs}s] interval too short – cycle took {elapsed:.3f}s ≥ interval {interval_group.interval}s; running at max possible speed"
)
# Immediate catch-up if a tick arrived during our run.
# Only perform catch-up when the previous poll succeeded and did not consume
# the complete interval; otherwise this creates an endless backlog.
if getattr(interval_group, "pending_rerun", False):
interval_group.pending_rerun = False
if agg_res and elapsed < (interval_group.interval or 0):
# Loop again immediately (no sleep) to catch up once
continue
if agg_res:
_LOGGER.debug(f"{self._name}: dropping pending catch-up because the previous poll already consumed the interval")
else:
_LOGGER.debug(f"{self._name}: dropping pending catch-up due to failed poll (slowdown={self.slowdown})")
# Exit the loop; next attempt will occur per normal schedule/slowdown policy
break
break
_LOGGER.debug(f"{self._name}: starting timer loop for interval group: {interval}")
interval_group.unsub_interval_method = async_track_time_interval(self._hass, _refresh, timedelta(seconds=interval))
# Defensive check: Skip sensors with no device_info (shouldn't happen normally)
if sensor.device_info is None:
_LOGGER.error(
f"{self._name}: Sensor {sensor.entity_description.key} has no device_info - skipping registration. "
f"This may indicate a bug in sensor creation. "
f"_attr_device_info={getattr(sensor, '_attr_device_info', 'NO_ATTR')}"
)
return
device_key = self.device_group_key(sensor.device_info)
grp = interval_group.device_groups.setdefault(device_key, empty_hub_device_group_lambda())
_LOGGER.debug(f"{self._name}: adding sensor {sensor.entity_description.key} available: {sensor._attr_available} ")
grp.sensors.append(sensor)
self.blocks_changed = True # will force rebuild_blocks to be called
@callback
async def async_remove_solax_modbus_sensor(self, sensor: Any) -> None:
"""Remove data update."""
interval = self.scan_group(sensor)
interval_group = self.groups.get(interval, None)
if interval_group is None:
return
# Defensive check: Skip sensors with no device_info
if sensor.device_info is None:
_LOGGER.warning(f"{self._name}: Cannot remove sensor {sensor.entity_description.key} - no device_info")
return
device_key = self.device_group_key(sensor.device_info)
grp = interval_group.device_groups.get(device_key, None)
if grp is None:
return