Skip to content

Commit 4333dca

Browse files
committed
Isolate plugin state per hub
1 parent f378b5c commit 4333dca

4 files changed

Lines changed: 101 additions & 2 deletions

File tree

custom_components/solax_modbus/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -579,7 +579,7 @@ def __init__(
579579
self.sleepnone: list[str] = [] # sensors that will be cleared in sleepmode
580580
self.writequeue: dict[tuple[int, int], PendingWrite] = {} # requests to retry when the inverter wakes
581581
_LOGGER.debug(f"{self.name}: ready to call plugin to determine inverter type")
582-
self.plugin = plugin.plugin_instance # getPlugin(name).plugin_instance
582+
self.plugin = plugin.plugin_instance.create_hub_instance()
583583
self.plugin_module = plugin # Store plugin module for accessing module-level functions
584584
self._validate_register_func = getattr(plugin, "validate_register_data", None) # Cache function reference
585585
self.wakeupButton: Any = None

custom_components/solax_modbus/const.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import logging
22
import pathlib
33
from collections.abc import Callable, Sequence
4+
from copy import deepcopy
45
from dataclasses import dataclass
56
from datetime import datetime, timedelta
6-
from typing import Any
7+
from typing import Any, Self
78

89
from homeassistant.components.button import ButtonEntityDescription
910
from homeassistant.components.number import NumberEntityDescription
@@ -153,6 +154,10 @@ class plugin_base:
153154
auto_default_scangroup: str = SCAN_GROUP_FAST # only used when default_xxx_scangroup is set to SCAN_GROUP_AUTO
154155
auto_slow_scangroup: str = SCAN_GROUP_MEDIUM # only usedwhen default_xxx_scangroup is set to SCAN_GROUP_AUTO
155156

157+
def create_hub_instance(self) -> Self:
158+
"""Create an independent runtime plugin instance for one hub."""
159+
return deepcopy(self)
160+
156161
def isAwake(self, datadict: dict[str, Any]) -> bool:
157162
"""Check if inverter is awake."""
158163
return True # always awake by default
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""Tests for per-hub plugin runtime state."""
2+
3+
from dataclasses import replace
4+
from types import ModuleType, SimpleNamespace
5+
from typing import Any, cast
6+
7+
from homeassistant.const import CONF_NAME
8+
9+
from custom_components.solax_modbus import SolaXModbusHub
10+
from custom_components.solax_modbus.const import CONF_INTERFACE, plugin_base
11+
from custom_components.solax_modbus.plugin_sofar import battery_config as SofarBatteryConfig
12+
from custom_components.solax_modbus.plugin_sofar import plugin_instance as sofar_template
13+
from custom_components.solax_modbus.plugin_solinteg import plugin_instance as solinteg_template
14+
15+
16+
def make_hub(plugin_template: plugin_base, name: str) -> SolaXModbusHub:
17+
"""Create a hub without opening a real transport."""
18+
plugin_module = cast(ModuleType, SimpleNamespace(plugin_instance=plugin_template))
19+
entry = SimpleNamespace(options={CONF_NAME: name, CONF_INTERFACE: "test"})
20+
hass = SimpleNamespace()
21+
return SolaXModbusHub(cast(Any, hass), plugin_module, cast(Any, entry))
22+
23+
24+
def test_hubs_get_independent_plugin_instances() -> None:
25+
"""Each hub must receive its own plugin runtime object."""
26+
first = make_hub(sofar_template, "Sofar 1")
27+
second = make_hub(sofar_template, "Sofar 2")
28+
29+
assert first.plugin is not second.plugin
30+
assert first.plugin is not sofar_template
31+
assert second.plugin is not sofar_template
32+
33+
first.plugin.inverter_model = "First model"
34+
35+
assert second.plugin.inverter_model is None
36+
assert sofar_template.inverter_model is None
37+
38+
39+
def test_sofar_battery_runtime_state_is_isolated_per_hub() -> None:
40+
"""Battery discovery state from one Sofar hub must not leak to another."""
41+
first = make_hub(sofar_template, "Sofar 1")
42+
second = make_hub(sofar_template, "Sofar 2")
43+
first_battery_base = first.plugin.BATTERY_CONFIG
44+
second_battery_base = second.plugin.BATTERY_CONFIG
45+
template_battery_base = sofar_template.BATTERY_CONFIG
46+
47+
assert first_battery_base is not None
48+
assert second_battery_base is not None
49+
assert template_battery_base is not None
50+
first_battery = cast(SofarBatteryConfig, first_battery_base)
51+
second_battery = cast(SofarBatteryConfig, second_battery_base)
52+
template_battery = cast(SofarBatteryConfig, template_battery_base)
53+
assert first_battery is not second_battery
54+
assert first_battery is not template_battery
55+
56+
first_battery.number_strings = 2
57+
first_battery.number_cels_in_parallel = 3
58+
first_battery.selected_batt_nr = 1
59+
first_battery.selected_batt_pack_nr = 2
60+
first_battery.batt_pack_serials[1] = {2: "SOFAR-PACK-1"}
61+
62+
assert second_battery.number_strings is None
63+
assert second_battery.number_cels_in_parallel is None
64+
assert second_battery.selected_batt_nr is None
65+
assert second_battery.selected_batt_pack_nr is None
66+
assert second_battery.batt_pack_serials == {}
67+
assert template_battery.batt_pack_serials == {}
68+
69+
70+
def test_solinteg_runtime_descriptions_are_isolated_per_hub() -> None:
71+
"""MPPT-specific description changes must stay local to one Solinteg hub."""
72+
first = make_hub(solinteg_template, "Solinteg 1")
73+
second = make_hub(solinteg_template, "Solinteg 2")
74+
first_index = next(index for index, description in enumerate(first.plugin.SELECT_TYPES) if description.key == "shadow_scan")
75+
second_index = next(index for index, description in enumerate(second.plugin.SELECT_TYPES) if description.key == "shadow_scan")
76+
template_index = next(index for index, description in enumerate(solinteg_template.SELECT_TYPES) if description.key == "shadow_scan")
77+
second_description = second.plugin.SELECT_TYPES[second_index]
78+
template_description = solinteg_template.SELECT_TYPES[template_index]
79+
80+
first.plugin.SELECT_TYPES[first_index] = replace(
81+
first.plugin.SELECT_TYPES[first_index],
82+
option_dict={0: "off", 1: "mppt1"},
83+
)
84+
85+
assert first.plugin.SELECT_TYPES[first_index].option_dict == {0: "off", 1: "mppt1"}
86+
assert second.plugin.SELECT_TYPES[second_index] == second_description
87+
assert solinteg_template.SELECT_TYPES[template_index] == template_description

tests/unit/test_plugins_structure.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,16 @@ def test_plugin_structure(plugin_module_name: str) -> None:
2626
# 2. Verify inheritance
2727
assert isinstance(plugin, plugin_base), f"{plugin_module_name} plugin_instance must inherit from plugin_base"
2828

29+
runtime_plugin = plugin.create_hub_instance()
30+
assert runtime_plugin is not plugin, f"{plugin_module_name} must create a separate runtime plugin per hub"
31+
2932
# 3. Verify attributes
3033
assert plugin.plugin_name, f"{plugin_module_name} missing plugin_name"
3134
assert isinstance(plugin.SENSOR_TYPES, list), f"{plugin_module_name} SENSOR_TYPES must be a list"
35+
for collection_name in ("SENSOR_TYPES", "BUTTON_TYPES", "NUMBER_TYPES", "SELECT_TYPES", "SWITCH_TYPES", "TIME_TYPES"):
36+
assert getattr(runtime_plugin, collection_name) is not getattr(plugin, collection_name), (
37+
f"{plugin_module_name} runtime plugin must not share {collection_name} with its template"
38+
)
3239

3340
# 4. Verify Entity Integrity (basic check)
3441
all_entities: list[Any] = (

0 commit comments

Comments
 (0)