Skip to content

Commit 58c3cba

Browse files
authored
Merge master (921179b) into release/4.4.0
2 parents f4d103a + 921179b commit 58c3cba

16 files changed

Lines changed: 400 additions & 44 deletions

‎custom_components/solis_modbus/config_flow.py‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@
3333
POLL_PROFILE_FULL,
3434
POLL_PROFILES,
3535
)
36-
from .data.enums import InverterType
3736
from .data.solis_config import CONNECTION_METHOD, SOLIS_INVERTERS, InverterConfig, inverter_options_from_config
3837
from .serial_proxy import SerialProxyClient, is_serial_proxy
3938

@@ -348,7 +347,7 @@ async def _validate_config(self, user_input):
348347

349348
conn_type = user_input.get(CONF_CONNECTION_TYPE, CONN_TYPE_SERIAL)
350349
device_id = user_input.get("slave", 1)
351-
probe_register = 3041 if inverter_config.type in [InverterType.GRID, InverterType.STRING] else 35000
350+
probe_register = inverter_config.probe_register
352351

353352
if conn_type == CONN_TYPE_TCP:
354353
host, port = user_input["host"], user_input.get("port", 502)

‎custom_components/solis_modbus/data/solis_config.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,11 @@ def _rebuild_features(self) -> None:
7878
feats.append(InverterFeature.TCP)
7979
self.features: list[InverterFeature] = feats
8080

81+
@property
82+
def probe_register(self) -> int:
83+
"""An input register every inverter of this type answers, for checking a unit responds."""
84+
return 3041 if self.type in [InverterType.GRID, InverterType.STRING] else 35000
85+
8186
def clone_with_options(self, options: InverterOptions, connection: str) -> InverterConfig:
8287
"""Copy this model definition with user-chosen options (does not mutate SOLIS_INVERTERS templates)."""
8388
return InverterConfig(

‎custom_components/solis_modbus/data_retrieval.py‎

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,12 @@
1212
from custom_components.solis_modbus.helpers import (
1313
cache_get,
1414
cache_save,
15+
iter_controllers,
1516
mark_platform_entities_unavailable_for_base_sensors,
1617
notify_register_update,
1718
)
1819

19-
from .const import DOMAIN
20+
from .const import CONN_TYPE_SERIAL, DOMAIN
2021
from .data.enums import PollSpeed
2122
from .modbus_controller import RECOVERABLE_REGISTER_READ_EXCEPTIONS, ModbusController
2223
from .sensors.solis_base_sensor import SolisSensorGroup, cluster_sensors_by_contiguous_registers
@@ -33,6 +34,10 @@
3334
# as night: inverters drop off at dusk and wake well after the sun is up.
3435
_NIGHT_GRACE = timedelta(hours=1)
3536

37+
# How long a serial inverter must go unanswered from startup before we check
38+
# whether it answers at unit 1 instead of its configured slave ID.
39+
_SLAVE_PROBE_AFTER = timedelta(minutes=1)
40+
3641

3742
class DataRetrieval:
3843
def __init__(
@@ -51,6 +56,11 @@ def __init__(
5156
# None = unknown (an issue may survive from before a restart), so the first
5257
# night-time pass still clears it; afterwards we skip redundant deletes.
5358
self._issue_raised: bool | None = None
59+
# Set once the slave ID check is settled (see _check_serial_slave_id).
60+
self._slave_id_checked = False
61+
# Set once its issue is raised: stop probing, but keep checking whether to clear it.
62+
self._slave_id_issue_raised = False
63+
self._polling_since: float | None = None
5464
self.poll_lock = asyncio.Lock()
5565
self.connection_check = False
5666
self.first_poll = True
@@ -196,6 +206,9 @@ async def async_stop(self):
196206
# the datalogger is offline — otherwise it keeps spinning after unload).
197207
self._stopping = True
198208
self._update_connection_issue(False)
209+
if self._entry_id is not None:
210+
# A reload re-raises it if it still applies.
211+
ir.async_delete_issue(self.hass, DOMAIN, f"serial_slave_id_{self._entry_id}")
199212

200213
# Clean up the startup listener only if it hasn't fired yet
201214
if self._startup_unsub:
@@ -276,6 +289,53 @@ def _update_connection_issue(self, unreachable: bool) -> None:
276289
ir.async_delete_issue(self.hass, DOMAIN, issue_id)
277290
self._issue_raised = False
278291

292+
async def _check_serial_slave_id(self) -> None:
293+
"""Raise a repair issue when a serial inverter only answers at unit 1.
294+
295+
Serial requests used to go to unit 1 whatever slave ID was configured, so
296+
an entry with the wrong slave ID kept working. Now that requests go to the
297+
configured slave, such an entry gets no data. While the configured slave
298+
has never answered, see whether unit 1 does.
299+
"""
300+
if self._slave_id_checked or self._entry_id is None:
301+
return
302+
controller = self.controller
303+
issue_id = f"serial_slave_id_{self._entry_id}"
304+
if controller.connection_type != CONN_TYPE_SERIAL or controller.device_id == 1 or controller.has_answered:
305+
# Also clears the issue once the entry is reconfigured or its slave answers.
306+
ir.async_delete_issue(self.hass, DOMAIN, issue_id)
307+
self._slave_id_checked = True
308+
return
309+
if any(other is not controller and other.connection_id == controller.connection_id and other.device_id == 1 for other in iter_controllers(self.hass)):
310+
# Unit 1 is another inverter configured on this bus; an answer proves nothing.
311+
# Not settled: that entry may be removed later.
312+
ir.async_delete_issue(self.hass, DOMAIN, issue_id)
313+
self._slave_id_issue_raised = False
314+
return
315+
if self._slave_id_issue_raised:
316+
return
317+
if self._polling_since is None or time.monotonic() - self._polling_since < _SLAVE_PROBE_AFTER.total_seconds():
318+
return
319+
if not await controller.async_unit_answers(1):
320+
# Nothing at unit 1 either (asleep, wiring, ...); check again next time.
321+
return
322+
self._slave_id_issue_raised = True
323+
_LOGGER.warning(
324+
"(%s.%s) No answer from slave ID %s, but a device answers at slave ID 1. If that is this inverter, reconfigure the entry with slave ID 1.",
325+
controller.host,
326+
controller.slave,
327+
controller.device_id,
328+
)
329+
ir.async_create_issue(
330+
self.hass,
331+
DOMAIN,
332+
issue_id,
333+
is_fixable=False,
334+
severity=ir.IssueSeverity.ERROR,
335+
translation_key="serial_slave_id",
336+
translation_placeholders={"port": str(controller.connection_id), "slave": str(controller.device_id)},
337+
)
338+
279339
async def check_connection(self, now=None):
280340
"""Ensure the Modbus controller is connected, retrying on failure.
281341
@@ -297,6 +357,8 @@ async def check_connection(self, now=None):
297357
# until a manual reload. Use try/finally so it is always reset.
298358
self.connection_check = True
299359
try:
360+
await self._check_serial_slave_id()
361+
300362
# Emit controller status (dispatcher — not persisted to recorder)
301363
notify_register_update(self.hass, self.controller, 90005, self.controller.enabled)
302364

@@ -363,6 +425,7 @@ async def poll_controller(self, event=None):
363425
if event is not None:
364426
self._startup_unsub = None
365427

428+
self._polling_since = time.monotonic()
366429
await self.check_connection()
367430

368431
# Start periodic polling

‎custom_components/solis_modbus/modbus_controller.py‎

Lines changed: 28 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,9 @@ def __init__(
138138
# Modbus Write Queue
139139
self.write_queue = asyncio.Queue()
140140
self._last_modbus_success = datetime.now(UTC)
141+
# Whether this inverter has answered a read since startup (unlike
142+
# _last_modbus_success, which starts at "now").
143+
self.has_answered = False
141144

142145
async def process_write_queue(self):
143146
"""Process queued Modbus write requests sequentially.
@@ -191,12 +194,7 @@ async def _execute_write_holding_register(self, register, value):
191194
int_value = int(value)
192195
int_register = register if is_number(register) else int(register)
193196

194-
# Different pymodbus APIs for TCP vs Serial
195-
if self.connection_type == CONN_TYPE_TCP:
196-
result = await self.client.write_register(address=int_register, value=int_value, device_id=self.device_id)
197-
else:
198-
self.client.slave = self.device_id
199-
result = await self.client.write_register(address=int_register, value=int_value)
197+
result = await self.client.write_register(address=int_register, value=int_value, device_id=self.device_id)
200198
_LOGGER.debug(
201199
f"({self.host}.{self.device_id}) Write Holding Register register = {int_register}, value = {value}, int_value = {int_value}: {result}"
202200
)
@@ -232,12 +230,7 @@ async def _execute_write_holding_registers(self, start_register, values):
232230
await self.inter_frame_wait(is_write=True) # Delay before write
233231

234232
try:
235-
# Different pymodbus APIs for TCP vs Serial
236-
if self.connection_type == CONN_TYPE_TCP:
237-
result = await self.client.write_registers(address=start_register, values=values, device_id=self.device_id)
238-
else:
239-
self.client.slave = self.device_id
240-
result = await self.client.write_registers(address=start_register, values=values)
233+
result = await self.client.write_registers(address=start_register, values=values, device_id=self.device_id)
241234
_LOGGER.debug(
242235
f"({self.host}.{self.device_id}) Write Holding Register block for {len(values)} registers starting at register = {start_register}"
243236
)
@@ -296,11 +289,7 @@ async def _async_read_input_register_raw_detailed(self, register: int, count: in
296289
await self.inter_frame_wait()
297290

298291
try:
299-
if self.connection_type == CONN_TYPE_TCP:
300-
result = await self.client.read_input_registers(address=register, count=count, device_id=self.device_id)
301-
else:
302-
self.client.slave = self.device_id
303-
result = await self.client.read_input_registers(address=register, count=count)
292+
result = await self.client.read_input_registers(address=register, count=count, device_id=self.device_id)
304293

305294
_LOGGER.debug("(%s.%s) Read Input Registers: register = %s, count = %s", self.host, self.device_id, register, count)
306295

@@ -311,6 +300,7 @@ async def _async_read_input_register_raw_detailed(self, register: int, count: in
311300
return None, exc
312301

313302
self._last_modbus_success = datetime.now(UTC)
303+
self.has_answered = True
314304
return result.registers, None
315305
except Exception as e:
316306
# Log the exception, close connection, and return error
@@ -364,11 +354,7 @@ async def _async_read_holding_register_raw_detailed(self, register: int, count:
364354
await self.inter_frame_wait()
365355

366356
try:
367-
if self.connection_type == CONN_TYPE_TCP:
368-
result = await self.client.read_holding_registers(address=register, count=count, device_id=self.device_id)
369-
else:
370-
self.client.slave = self.device_id
371-
result = await self.client.read_holding_registers(address=register, count=count)
357+
result = await self.client.read_holding_registers(address=register, count=count, device_id=self.device_id)
372358

373359
_LOGGER.debug("(%s.%s) Read Holding Registers: register = %s, count = %s", self.host, self.device_id, register, count)
374360

@@ -379,6 +365,7 @@ async def _async_read_holding_register_raw_detailed(self, register: int, count:
379365
return None, exc
380366

381367
self._last_modbus_success = datetime.now(UTC)
368+
self.has_answered = True
382369
return result.registers, None
383370
except Exception as e:
384371
# Log the exception, close connection, and return error
@@ -431,6 +418,25 @@ def _log_skipped_read(self, kind: str, register: int, count: int) -> None:
431418
# watchdog in DataRetrieval owns recovery, so stay quiet here.
432419
_LOGGER.debug("(%s.%s) Skipping %s register read at %s (count=%s): not connected", self.host, self.device_id, kind, register, count)
433420

421+
async def async_unit_answers(self, device_id: int) -> bool:
422+
"""Return whether some unit on this link answers a read of the probe register.
423+
424+
Unlike the register reads, an answer doesn't count as this inverter
425+
answering (has_answered, last_modbus_success): it may be another unit.
426+
"""
427+
if not await self.connect():
428+
return False
429+
register = self.inverter_config.probe_register
430+
async with self.poll_lock:
431+
await self.inter_frame_wait()
432+
try:
433+
result = await self.client.read_input_registers(address=register, count=1, device_id=device_id)
434+
except Exception as e:
435+
_LOGGER.debug("(%s.%s) No answer from unit %s at register %s: %s", self.host, self.device_id, device_id, register, e)
436+
self._safe_close()
437+
return False
438+
return not result.isError()
439+
434440
async def connect(self):
435441
"""Establishes a connection to the Modbus device.
436442

‎custom_components/solis_modbus/serial_proxy.py‎

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,6 @@ def __init__(
7676
# retries is accepted for pymodbus compatibility; modbus-connection
7777
# doesn't retry, and the controller's own recovery handles failures.
7878
self.port = port
79-
# The controller sets this on serial clients; requests use it unless
80-
# they pass device_id.
81-
self.slave = 1
8279
self._connection = ModbusConnection(
8380
ModbusSerialParams(device=port, baudrate=baudrate, bytesize=bytesize, parity=parity, stopbits=stopbits),
8481
timeout=timeout,
@@ -132,27 +129,27 @@ async def _await_teardown(self) -> None:
132129
# Shielded: a cancelled request must not cancel the teardown.
133130
await asyncio.shield(task)
134131

135-
async def _unit(self, device_id: int | None):
132+
async def _unit(self, device_id: int):
136133
await self._await_teardown()
137-
return self._connection.for_unit(self.slave if device_id is None else device_id)
134+
return self._connection.for_unit(device_id)
138135

139-
async def read_input_registers(self, address: int, *, count: int = 1, device_id: int | None = None) -> SerialProxyResult:
136+
async def read_input_registers(self, address: int, *, count: int = 1, device_id: int = 1) -> SerialProxyResult:
140137
"""Read input registers (FC04)."""
141138
unit = await self._unit(device_id)
142139
try:
143140
return SerialProxyResult(list(await unit.read_input_registers(address, count)))
144141
except ModbusExceptionError as err:
145142
return SerialProxyResult(error=err)
146143

147-
async def read_holding_registers(self, address: int, *, count: int = 1, device_id: int | None = None) -> SerialProxyResult:
144+
async def read_holding_registers(self, address: int, *, count: int = 1, device_id: int = 1) -> SerialProxyResult:
148145
"""Read holding registers (FC03)."""
149146
unit = await self._unit(device_id)
150147
try:
151148
return SerialProxyResult(list(await unit.read_holding_registers(address, count)))
152149
except ModbusExceptionError as err:
153150
return SerialProxyResult(error=err)
154151

155-
async def write_register(self, address: int, value: int, *, device_id: int | None = None) -> SerialProxyResult:
152+
async def write_register(self, address: int, value: int, *, device_id: int = 1) -> SerialProxyResult:
156153
"""Write one holding register (FC06); the result echoes the value, as pymodbus's does."""
157154
unit = await self._unit(device_id)
158155
try:
@@ -161,7 +158,7 @@ async def write_register(self, address: int, value: int, *, device_id: int | Non
161158
return SerialProxyResult(error=err)
162159
return SerialProxyResult([value])
163160

164-
async def write_registers(self, address: int, values: list[int], *, device_id: int | None = None) -> SerialProxyResult:
161+
async def write_registers(self, address: int, values: list[int], *, device_id: int = 1) -> SerialProxyResult:
165162
"""Write consecutive holding registers (FC16)."""
166163
unit = await self._unit(device_id)
167164
try:

‎custom_components/solis_modbus/translations/af.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,10 @@
192192
"datalogger_unreachable": {
193193
"title": "Solis datalogger onbereikbaar",
194194
"description": "Geen suksesvolle Modbus-lesing vanaf {host} sedert {last_success} nie. Die WiFi-datalogger slaap dalk, is afgeskakel of in 'n half-oop toestand; die integrasie probeer aanhoudend weer met terugval. Skakel die datalogger se krag af en aan as dit voortduur."
195+
},
196+
"serial_slave_id": {
197+
"title": "Solis-omsetter antwoord nie by slaaf-ID {slave} nie",
198+
"description": "Die omsetter op {port} antwoord nie by sy gekonfigureerde Modbus-slaaf-ID {slave} nie, maar 'n toestel op die bus antwoord by slaaf-ID 1. Ouer weergawes van hierdie integrasie het elke seriële (RS485) versoek na slaaf-ID 1 gestuur, ongeag die gekonfigureerde ID, so hierdie opstelling het dalk tot nou gewerk. As die omsetter by slaaf-ID 1 is, herkonfigureer die integrasie-inskrywing en stel die slaaf-ID op 1."
195199
}
196200
}
197201
}

‎custom_components/solis_modbus/translations/de.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,10 @@
192192
"datalogger_unreachable": {
193193
"title": "Solis-Datenlogger nicht erreichbar",
194194
"description": "Kein erfolgreicher Modbus-Lesevorgang von {host} seit {last_success}. Der WiFi-Datenlogger schläft möglicherweise, ist ausgeschaltet oder in einem halboffenen Zustand; die Integration versucht es weiterhin mit Backoff. Bei anhaltendem Problem den Datenlogger stromlos machen."
195+
},
196+
"serial_slave_id": {
197+
"title": "Solis-Wechselrichter antwortet nicht unter Slave-ID {slave}",
198+
"description": "Der Wechselrichter an {port} antwortet nicht unter seiner konfigurierten Modbus-Slave-ID {slave}, aber ein Gerät am Bus antwortet unter Slave-ID 1. Ältere Versionen dieser Integration haben jede serielle (RS485) Anfrage unabhängig von der konfigurierten ID an Slave-ID 1 gesendet, daher hat diese Konfiguration bisher möglicherweise funktioniert. Wenn der Wechselrichter Slave-ID 1 hat, den Integrationseintrag neu konfigurieren und die Slave-ID auf 1 setzen."
195199
}
196200
}
197201
}

‎custom_components/solis_modbus/translations/en.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,10 @@
192192
"datalogger_unreachable": {
193193
"title": "Solis datalogger unreachable",
194194
"description": "No successful Modbus read from {host} since {last_success}. The WiFi datalogger may be asleep, powered off or in a half-open state; the integration keeps retrying with backoff. Power-cycle the datalogger if this persists."
195+
},
196+
"serial_slave_id": {
197+
"title": "Solis inverter not answering at slave ID {slave}",
198+
"description": "The inverter on {port} doesn't answer at its configured Modbus slave ID {slave}, but a device on the bus answers at slave ID 1. Older versions of this integration sent every serial (RS485) request to slave ID 1 whatever ID was configured, so this setup may have worked until now. If the inverter is at slave ID 1, reconfigure the integration entry and set the slave ID to 1."
195199
}
196200
}
197201
}

‎custom_components/solis_modbus/translations/es.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,10 @@
192192
"datalogger_unreachable": {
193193
"title": "Datalogger de Solis inaccesible",
194194
"description": "Sin lecturas Modbus correctas desde {host} desde {last_success}. El datalogger WiFi puede estar dormido, apagado o en estado semiabierto; la integración sigue reintentando con espera progresiva. Reinicie la alimentación del datalogger si persiste."
195+
},
196+
"serial_slave_id": {
197+
"title": "El inversor Solis no responde en el ID de esclavo {slave}",
198+
"description": "El inversor en {port} no responde en su ID de esclavo Modbus configurado {slave}, pero un dispositivo del bus responde en el ID de esclavo 1. Las versiones anteriores de esta integración enviaban todas las peticiones serie (RS485) al ID de esclavo 1, fuera cual fuera el ID configurado, así que esta configuración puede haber funcionado hasta ahora. Si el inversor está en el ID de esclavo 1, reconfigure la entrada de la integración y ponga el ID de esclavo en 1."
195199
}
196200
}
197201
}

0 commit comments

Comments
 (0)