Skip to content

Commit 914abcc

Browse files
committed
Fix solis_write_holding_register host matching and swallowed errors (#12)
get_controller matched on a hub's .host property, which delegates to whichever protocol is active and can be the literal string "soliscloud" - so host-based service calls silently stopped matching after failover. It now also accepts a protocol-stable serial, and host/slave matching reads the hub's .modbus sub-controller directly. The service handler fired writes into an untracked hass.create_task, so failures vanished silently. It now awaits the write directly and raises HomeAssistantError on failure, and requires an explicit target when multiple inverters are configured instead of broadcasting to all of them. Fixes #12
1 parent f5cf189 commit 914abcc

4 files changed

Lines changed: 156 additions & 15 deletions

File tree

‎custom_components/solisconnect/__init__.py‎

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from homeassistant.config_entries import ConfigEntry
1010
from homeassistant.const import Platform
1111
from homeassistant.core import HomeAssistant, ServiceCall
12-
from homeassistant.exceptions import ConfigEntryError
12+
from homeassistant.exceptions import ConfigEntryError, HomeAssistantError, ServiceValidationError
1313
from homeassistant.helpers.device_registry import DeviceEntry
1414

1515
from .const import (
@@ -60,6 +60,8 @@
6060
vol.Required("address"): vol.Coerce(int),
6161
vol.Required("value"): vol.Coerce(int),
6262
vol.Optional("host"): vol.Coerce(str),
63+
vol.Optional("slave"): vol.Coerce(int),
64+
vol.Optional("serial"): vol.Coerce(str),
6365
}
6466
)
6567
SCHEME_TIME_SET = vol.Schema({vol.Required("entity_id"): vol.Coerce(str), vol.Required("time"): vol.Coerce(str)})
@@ -73,18 +75,31 @@ async def async_remove_config_entry_device(hass: HomeAssistant, config_entry: Co
7375
async def async_setup(hass: HomeAssistant, entry: ConfigEntry):
7476
"""Set up the Modbus integration."""
7577

76-
def service_write_holding_register(call: ServiceCall):
78+
async def service_write_holding_register(call: ServiceCall):
7779
address = call.data.get("address")
7880
value = call.data.get("value")
7981
host = call.data.get("host")
8082
slave = call.data.get("slave", 1)
83+
serial = call.data.get("serial")
8184

82-
if host:
83-
controller = get_controller(hass, host, slave)
84-
hass.create_task(controller.async_write_holding_register(int(address), int(value)))
85+
if serial or host:
86+
controller = get_controller(hass, host, slave, serial=serial)
87+
if controller is None:
88+
raise ServiceValidationError(f"No configured SolisConnect inverter matches serial={serial!r} host={host!r} slave={slave}")
8589
else:
86-
for controller in hass.data[DOMAIN][CONTROLLER].values():
87-
hass.create_task(controller.async_write_holding_register(int(address), int(value)))
90+
controllers = list(hass.data.get(DOMAIN, {}).get(CONTROLLER, {}).values())
91+
if not controllers:
92+
raise ServiceValidationError("No configured SolisConnect inverter found")
93+
if len(controllers) > 1:
94+
raise ServiceValidationError("Multiple SolisConnect inverters are configured; specify a target 'serial' or 'host' to disambiguate")
95+
controller = controllers[0]
96+
97+
try:
98+
await controller.async_write_holding_register(int(address), int(value))
99+
except HomeAssistantError:
100+
raise
101+
except Exception as error:
102+
raise HomeAssistantError(f"Writing register {address}={value} failed: {error}") from error
88103

89104
async def service_set_time(call: ServiceCall) -> None:
90105
"""Service to update a Solis time entity."""

‎custom_components/solisconnect/helpers.py‎

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -245,15 +245,31 @@ def get_controller_from_entry(hass: HomeAssistant, config_entry: ConfigEntry):
245245
return hass.data[DOMAIN][CONTROLLER].get(config_entry.entry_id)
246246

247247

248-
def get_controller(hass: HomeAssistant, host: str, slave: int = 1):
248+
def get_controller(hass: HomeAssistant, host: str | None = None, slave: int = 1, serial: str | None = None):
249249
"""
250-
Search for a controller matching the host and slave ID.
251-
Used by services (write_holding_register) that only know the IP/Host.
250+
Find a stored controller (a SolisInverterHub, per config entry) by inverter serial or
251+
by its configured Modbus host/slave. Used by services (write_holding_register) that
252+
only know a target host or serial.
253+
254+
Serial is protocol-stable and preferred: a hub's `host`/`device_id` properties delegate
255+
to whichever protocol is currently active (e.g. the literal string "soliscloud" while
256+
cloud is active), so host/slave matching instead reads the hub's `.modbus` sub-controller
257+
directly, which always reflects its real configured Modbus link regardless of which
258+
protocol is currently active.
252259
"""
253260
for controller in hass.data[DOMAIN][CONTROLLER].values():
254-
# Check if this controller matches the requested host and slave
255-
# Use getattr to be safe if controller hasn't fully initialized
256-
if getattr(controller, "host", None) == host and getattr(controller, "device_id", 0) == slave:
261+
if serial is not None:
262+
if getattr(controller, "serial_number", None) == serial:
263+
return controller
264+
continue
265+
modbus = getattr(controller, "modbus", None)
266+
if modbus is not None:
267+
configured_host = getattr(modbus, "host", None)
268+
configured_slave = getattr(modbus, "device_id", None)
269+
else:
270+
configured_host = getattr(controller, "host", None)
271+
configured_slave = getattr(controller, "device_id", None)
272+
if configured_host == host and configured_slave == slave:
257273
return controller
258274
return None
259275

‎custom_components/solisconnect/services.yaml‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,17 @@ solis_write_holding_register:
1010
default: ''
1111
example: 500
1212
host:
13-
description: IP of the inverter, only required when running multiple inverters
13+
description: Configured Modbus IP of the target inverter; only required when running multiple inverters. Prefer 'serial' for dual-protocol/cloud entries, since 'host' matches the inverter's configured Modbus link regardless of which protocol is currently active.
1414
default: ''
1515
example: 192.168.1.75
16+
slave:
17+
description: Modbus slave/unit ID of the target inverter, when disambiguating by host on a shared link
18+
default: 1
19+
example: 1
20+
serial:
21+
description: Inverter serial number of the target inverter; protocol-stable, works for Modbus, cloud, and dual-protocol entries
22+
default: ''
23+
example: '1234567890123456'
1624
solis_write_time:
1725
description: Writes a time string to an entity
1826
fields:

‎tests/test_services.py‎

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,18 @@
22

33
import pytest
44
from homeassistant.core import HomeAssistant
5+
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
56

6-
from custom_components.solisconnect.const import DOMAIN
7+
from custom_components.solisconnect.const import CONTROLLER, DOMAIN
8+
from custom_components.solisconnect.helpers import get_controller
79

810

911
@pytest.fixture
1012
def mock_controller():
1113
controller = MagicMock()
1214
controller.host = "1.2.3.4"
1315
controller.device_id = 1
16+
controller.modbus = None # simulates a cloud-only/bare controller; hubs set this to their sub-controller
1417
controller.async_write_holding_register = AsyncMock(return_value=None)
1518
return controller
1619

@@ -53,6 +56,105 @@ async def test_service_write_holding_register_no_host(hass: HomeAssistant, mock_
5356
mock_controller.async_write_holding_register.assert_called_with(123, 456)
5457

5558

59+
@pytest.mark.asyncio
60+
async def test_service_write_holding_register_no_target_with_multiple_controllers_raises(hass: HomeAssistant, mock_controller):
61+
"""Bug #12: silently broadcasting a write to every configured inverter is dangerous; require a target."""
62+
other_controller = MagicMock()
63+
other_controller.host = "5.6.7.8"
64+
other_controller.device_id = 1
65+
other_controller.async_write_holding_register = AsyncMock(return_value=None)
66+
67+
hass.data.setdefault(DOMAIN, {})
68+
hass.data[DOMAIN][CONTROLLER] = {"1.2.3.4_1": mock_controller, "5.6.7.8_1": other_controller}
69+
70+
from custom_components.solisconnect import async_setup
71+
72+
await async_setup(hass, {})
73+
74+
with pytest.raises(ServiceValidationError, match="Multiple"):
75+
await hass.services.async_call(DOMAIN, "solis_write_holding_register", {"address": 123, "value": 456}, blocking=True)
76+
77+
mock_controller.async_write_holding_register.assert_not_called()
78+
other_controller.async_write_holding_register.assert_not_called()
79+
80+
81+
@pytest.mark.asyncio
82+
async def test_service_write_holding_register_unmatched_target_raises(hass: HomeAssistant, mock_controller):
83+
hass.data.setdefault(DOMAIN, {})
84+
hass.data[DOMAIN][CONTROLLER] = {"1.2.3.4_1": mock_controller}
85+
86+
from custom_components.solisconnect import async_setup
87+
88+
await async_setup(hass, {})
89+
90+
with pytest.raises(ServiceValidationError, match="No configured"):
91+
await hass.services.async_call(DOMAIN, "solis_write_holding_register", {"address": 123, "value": 456, "host": "9.9.9.9"}, blocking=True)
92+
93+
mock_controller.async_write_holding_register.assert_not_called()
94+
95+
96+
@pytest.mark.asyncio
97+
async def test_service_write_holding_register_matches_by_serial(hass: HomeAssistant, mock_controller):
98+
"""Serial matching must work even while the hub's own `host` property is protocol-dependent."""
99+
mock_controller.host = "soliscloud" # simulates a dual-protocol hub currently active on cloud
100+
mock_controller.serial_number = "SN12345"
101+
102+
hass.data.setdefault(DOMAIN, {})
103+
hass.data[DOMAIN][CONTROLLER] = {"1.2.3.4_1": mock_controller}
104+
105+
from custom_components.solisconnect import async_setup
106+
107+
await async_setup(hass, {})
108+
109+
await hass.services.async_call(DOMAIN, "solis_write_holding_register", {"address": 123, "value": 456, "serial": "SN12345"}, blocking=True)
110+
111+
mock_controller.async_write_holding_register.assert_called_with(123, 456)
112+
113+
114+
@pytest.mark.asyncio
115+
async def test_service_write_holding_register_write_failure_raises(hass: HomeAssistant, mock_controller):
116+
"""Bug #12: a write failure must surface as a service error, not vanish into a fire-and-forget task."""
117+
mock_controller.async_write_holding_register = AsyncMock(side_effect=RuntimeError("boom"))
118+
119+
hass.data.setdefault(DOMAIN, {})
120+
hass.data[DOMAIN][CONTROLLER] = {"1.2.3.4_1": mock_controller}
121+
122+
from custom_components.solisconnect import async_setup
123+
124+
await async_setup(hass, {})
125+
126+
with pytest.raises(HomeAssistantError, match="boom"):
127+
await hass.services.async_call(DOMAIN, "solis_write_holding_register", {"address": 123, "value": 456, "host": "1.2.3.4"}, blocking=True)
128+
129+
130+
def test_get_controller_matches_host_via_modbus_sub_controller_when_hub_is_cloud_active():
131+
"""Bug #12: a hub's `.host` delegates to the active protocol (e.g. "soliscloud"); host matching
132+
must read the hub's `.modbus` sub-controller instead, so it still works after failover."""
133+
hub = MagicMock()
134+
hub.host = "soliscloud" # currently active protocol is cloud
135+
hub.device_id = 1 # cloud controller's hardcoded device_id, not the real Modbus slave
136+
hub.modbus.host = "1.2.3.4" # the hub's real, protocol-independent Modbus link
137+
hub.modbus.device_id = 3
138+
139+
hass = MagicMock()
140+
hass.data = {DOMAIN: {CONTROLLER: {"entry1": hub}}}
141+
142+
assert get_controller(hass, host="1.2.3.4", slave=3) is hub
143+
assert get_controller(hass, host="soliscloud", slave=1) is None # the active-protocol host must not match
144+
145+
146+
def test_get_controller_matches_serial_regardless_of_host():
147+
hub = MagicMock()
148+
hub.serial_number = "SN999"
149+
hub.host = "soliscloud"
150+
151+
hass = MagicMock()
152+
hass.data = {DOMAIN: {CONTROLLER: {"entry1": hub}}}
153+
154+
assert get_controller(hass, serial="SN999") is hub
155+
assert get_controller(hass, serial="SNOTHER") is None
156+
157+
56158
@pytest.mark.asyncio
57159
async def test_service_set_time(hass: HomeAssistant):
58160
"""Test solis_write_time service."""

0 commit comments

Comments
 (0)