Skip to content

Commit 8c119d7

Browse files
authored
Enhance configuration, add Bluetooth status and Venus D support (#140)
2 parents 5ae0a5f + 8fd1321 commit 8c119d7

File tree

16 files changed

+3391
-2082
lines changed

16 files changed

+3391
-2082
lines changed

custom_components/marstek_modbus/config_flow.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,10 +98,11 @@ async def async_step_user(self, user_input=None):
9898
except (socket.gaierror, TypeError):
9999
errors["base"] = "invalid_host"
100100
else:
101-
# Prevent duplicate entries for same host and unit_id
101+
# Prevent duplicate entries for same host, port and unit_id
102102
for entry in self._async_current_entries():
103103
if (
104104
entry.data.get(CONF_HOST) == host
105+
and entry.data.get(CONF_PORT) == port
105106
and entry.data.get(CONF_UNIT_ID) == unit_id
106107
):
107108
return self.async_abort(reason="already_configured")

custom_components/marstek_modbus/const.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@
2222
SUPPORTED_VERSIONS = [
2323
"E v1/v2",
2424
"E v3",
25-
"D"]
25+
"D",
26+
"A"]
2627

2728
# Note: register loading logic (get_registers_for_version) was moved to
2829
# `register_loader.py` to keep `const.py` focused on constants only.

custom_components/marstek_modbus/coordinator.py

Lines changed: 132 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from .const import DEFAULT_SCAN_INTERVALS, SUPPORTED_VERSIONS, DEFAULT_UNIT_ID
1515

1616
from .helpers.modbus_client import MarstekModbusClient
17+
from pathlib import Path
1718

1819
_LOGGER = logging.getLogger(__name__)
1920

@@ -110,6 +111,10 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry):
110111
self._connection_suspended = False
111112
self._suspension_reset_time = None
112113

114+
self._consecutive_timeout_cycles = 0
115+
self._max_consecutive_timeout_cycles = 3
116+
self._timeout_ratio_reconnect_threshold = 0.5
117+
113118
# Connection health tracking for diagnostics
114119
self._last_successful_read = None
115120
self._connection_established_at = None
@@ -215,8 +220,14 @@ async def async_init(self):
215220
_LOGGER.info("Successfully connected to Modbus device at %s:%d", self.host, self.port)
216221
return connected
217222

218-
async def async_read_value(self, sensor: dict, key: str):
219-
"""Helper to read a single sensor value from Modbus with logging and type checking."""
223+
async def async_read_value(self, sensor: dict, key: str, track_failure: bool = True):
224+
"""Helper to read a single sensor value from Modbus with logging and type checking.
225+
226+
Args:
227+
sensor: sensor definition dict
228+
key: the sensor key
229+
track_failure: if False, timeouts will not count towards timeout metrics
230+
"""
220231
entity_type = self._entity_types.get(key, get_entity_type(sensor))
221232

222233
# Determine scale and unit
@@ -260,9 +271,11 @@ async def async_read_value(self, sensor: dict, key: str):
260271
return None
261272

262273
except asyncio.TimeoutError:
274+
if track_failure:
275+
self._timeouts_in_cycle = getattr(self, "_timeouts_in_cycle", 0) + 1
263276
_LOGGER.warning(
264-
"Timeout reading %s '%s' at register %d - connection may be slow or incorrect",
265-
entity_type, key, sensor["register"]
277+
"Timeout reading %s '%s' at register %d from %s:%d - connection may be slow or incorrect",
278+
entity_type, key, sensor["register"], self.client.host, self.client.port
266279
)
267280
return None
268281
except Exception as e:
@@ -348,6 +361,7 @@ async def _async_update_data(self):
348361
# Track if we actually attempted any reads (not just skipped due to intervals)
349362
attempted_reads = 0
350363
successful_reads = 0
364+
self._timeouts_in_cycle = 0
351365

352366
# Connection throttling: if too many failures, temporarily stop attempting connections
353367
if self._connection_suspended:
@@ -358,14 +372,7 @@ async def _async_update_data(self):
358372

359373
# Force reconnect after suspension
360374
try:
361-
_LOGGER.debug("Closing existing connection before reconnect")
362-
await self.client.async_close()
363-
except Exception as exc:
364-
_LOGGER.debug("Error closing client during reconnect: %s", exc)
365-
366-
try:
367-
_LOGGER.info("Attempting to reconnect to %s:%d", self.host, self.port)
368-
connected = await self.client.async_connect()
375+
connected = await self.client.async_reconnect()
369376
if connected:
370377
_LOGGER.info("Successfully reconnected after suspension")
371378
else:
@@ -464,6 +471,7 @@ async def _async_update_data(self):
464471

465472
# Connection retry logic: only track failures if we actually attempted reads
466473
if attempted_reads > 0:
474+
timeout_reads = int(getattr(self, "_timeouts_in_cycle", 0) or 0)
467475
if successful_reads > 0:
468476
# At least some data successfully retrieved - reset failure counter
469477
if self._consecutive_failures > 0:
@@ -472,18 +480,46 @@ async def _async_update_data(self):
472480
self._consecutive_failures = 0
473481
self._connection_suspended = False
474482
self._last_successful_read = now
483+
484+
if timeout_reads and (timeout_reads / attempted_reads) >= self._timeout_ratio_reconnect_threshold:
485+
self._consecutive_timeout_cycles += 1
486+
_LOGGER.warning(
487+
"High timeout rate detected (%d/%d) - consecutive timeout cycles: %d/%d",
488+
timeout_reads,
489+
attempted_reads,
490+
self._consecutive_timeout_cycles,
491+
self._max_consecutive_timeout_cycles,
492+
)
493+
else:
494+
self._consecutive_timeout_cycles = 0
495+
496+
if self._consecutive_timeout_cycles >= self._max_consecutive_timeout_cycles:
497+
try:
498+
_LOGGER.info(
499+
"Attempting reconnect due to repeated timeouts (%d/%d cycles)",
500+
self._consecutive_timeout_cycles,
501+
self._max_consecutive_timeout_cycles,
502+
)
503+
connected = await self.client.async_reconnect()
504+
if connected:
505+
_LOGGER.info("Successfully reconnected after repeated timeouts")
506+
self._consecutive_timeout_cycles = 0
507+
self._connection_established_at = now
508+
else:
509+
_LOGGER.warning("Reconnect attempt after repeated timeouts failed")
510+
except Exception as exc:
511+
_LOGGER.error("Exception during reconnect after repeated timeouts: %s", exc)
475512
elif successful_reads == 0:
476513
# We attempted reads but ALL failed - connection issue
477514
self._consecutive_failures += 1
478515
_LOGGER.warning("All read attempts failed (%d/%d) - consecutive failures: %d/%d",
479516
successful_reads, attempted_reads,
480517
self._consecutive_failures, self._max_consecutive_failures)
481518

482-
# Try to reconnect immediately on failure
519+
# Try to reconnect immediately on failure (use reconnect helper)
483520
try:
484521
_LOGGER.info("Attempting immediate reconnection after read failures")
485-
await self.client.async_close()
486-
connected = await self.client.async_connect()
522+
connected = await self.client.async_reconnect()
487523
if connected:
488524
_LOGGER.info("Successfully reconnected")
489525
self._consecutive_failures = 0
@@ -502,6 +538,7 @@ async def _async_update_data(self):
502538
"Will retry in 5 minutes to prevent resource exhaustion.",
503539
self._consecutive_failures
504540
)
541+
self._consecutive_timeout_cycles = 0
505542
else:
506543
_LOGGER.debug("No sensors due for update in this cycle")
507544

@@ -567,22 +604,90 @@ def get_registers(version: str):
567604
% (version_raw, ", ".join(sorted(allowed)))
568605
)
569606

570-
# Map the validated version token to the correct registers module.
571-
# Support the new tokens 'e v1/v2' and 'e v3'.
607+
def _normalize_section(section):
608+
"""Convert mapping-based sections into the legacy list-of-dicts format."""
609+
if isinstance(section, dict):
610+
normalized = []
611+
for key, value in section.items():
612+
entry = dict(value or {})
613+
entry.setdefault("key", key)
614+
normalized.append(entry)
615+
return normalized
616+
if isinstance(section, list):
617+
return section
618+
return []
619+
620+
# Prefer YAML-based register definitions placed in the `registers/` folder.
621+
# Map version tokens to YAML filenames.
622+
filename_map = {
623+
"e v1/v2": "e_v12.yaml",
624+
"e v3": "e_v3.yaml",
625+
"d": "d.yaml",
626+
"a": "a.yaml",
627+
}
628+
629+
yaml_filename = filename_map.get(version)
630+
if yaml_filename:
631+
yaml_path = Path(__file__).parent / "registers" / yaml_filename
632+
if yaml_path.exists():
633+
try:
634+
import yaml
635+
636+
with open(yaml_path, "r", encoding="utf-8") as fh:
637+
data = yaml.safe_load(fh) or {}
638+
639+
return {
640+
"SENSOR_DEFINITIONS": _normalize_section(data.get("SENSOR_DEFINITIONS")),
641+
"BINARY_SENSOR_DEFINITIONS": _normalize_section(data.get("BINARY_SENSOR_DEFINITIONS")),
642+
"SELECT_DEFINITIONS": _normalize_section(data.get("SELECT_DEFINITIONS")),
643+
"SWITCH_DEFINITIONS": _normalize_section(data.get("SWITCH_DEFINITIONS")),
644+
"NUMBER_DEFINITIONS": _normalize_section(data.get("NUMBER_DEFINITIONS")),
645+
"BUTTON_DEFINITIONS": _normalize_section(data.get("BUTTON_DEFINITIONS")),
646+
"EFFICIENCY_SENSOR_DEFINITIONS": _normalize_section(
647+
data.get("EFFICIENCY_SENSOR_DEFINITIONS")
648+
),
649+
"STORED_ENERGY_SENSOR_DEFINITIONS": _normalize_section(
650+
data.get("STORED_ENERGY_SENSOR_DEFINITIONS")
651+
),
652+
}
653+
except Exception as e:
654+
_LOGGER.warning("Failed to load YAML registers %s: %s", yaml_path, e)
655+
656+
# Fall back to legacy Python modules if YAML not present or failed to load
572657
if version == "e v1/v2":
573658
from . import registers_v12 as registers
574659
elif version == "e v3":
575660
from . import registers_v3 as registers
576661
elif version == "d":
577-
from . import registers_v12 as registers
578-
662+
from . import registers_d as registers
663+
elif version == "a":
664+
# No legacy Python module for A exists; return empty definitions as fallback
665+
registers = None
666+
667+
if registers:
668+
return {
669+
"SENSOR_DEFINITIONS": getattr(registers, "SENSOR_DEFINITIONS", []),
670+
"BINARY_SENSOR_DEFINITIONS": getattr(registers, "BINARY_SENSOR_DEFINITIONS", []),
671+
"SELECT_DEFINITIONS": getattr(registers, "SELECT_DEFINITIONS", []),
672+
"SWITCH_DEFINITIONS": getattr(registers, "SWITCH_DEFINITIONS", []),
673+
"NUMBER_DEFINITIONS": getattr(registers, "NUMBER_DEFINITIONS", []),
674+
"BUTTON_DEFINITIONS": getattr(registers, "BUTTON_DEFINITIONS", []),
675+
"EFFICIENCY_SENSOR_DEFINITIONS": getattr(
676+
registers, "EFFICIENCY_SENSOR_DEFINITIONS", []
677+
),
678+
"STORED_ENERGY_SENSOR_DEFINITIONS": getattr(
679+
registers, "STORED_ENERGY_SENSOR_DEFINITIONS", []
680+
),
681+
}
682+
683+
# Default empty return if nothing found
579684
return {
580-
"SENSOR_DEFINITIONS": getattr(registers, "SENSOR_DEFINITIONS", []),
581-
"BINARY_SENSOR_DEFINITIONS": getattr(registers, "BINARY_SENSOR_DEFINITIONS", []),
582-
"SELECT_DEFINITIONS": getattr(registers, "SELECT_DEFINITIONS", []),
583-
"SWITCH_DEFINITIONS": getattr(registers, "SWITCH_DEFINITIONS", []),
584-
"NUMBER_DEFINITIONS": getattr(registers, "NUMBER_DEFINITIONS", []),
585-
"BUTTON_DEFINITIONS": getattr(registers, "BUTTON_DEFINITIONS", []),
586-
"EFFICIENCY_SENSOR_DEFINITIONS": getattr(registers, "EFFICIENCY_SENSOR_DEFINITIONS", []),
587-
"STORED_ENERGY_SENSOR_DEFINITIONS": getattr(registers, "STORED_ENERGY_SENSOR_DEFINITIONS", []),
685+
"SENSOR_DEFINITIONS": [],
686+
"BINARY_SENSOR_DEFINITIONS": [],
687+
"SELECT_DEFINITIONS": [],
688+
"SWITCH_DEFINITIONS": [],
689+
"NUMBER_DEFINITIONS": [],
690+
"BUTTON_DEFINITIONS": [],
691+
"EFFICIENCY_SENSOR_DEFINITIONS": [],
692+
"STORED_ENERGY_SENSOR_DEFINITIONS": [],
588693
}

custom_components/marstek_modbus/helpers/modbus_client.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,38 @@ async def async_close(self) -> None:
150150
except Exception:
151151
pass
152152

153+
async def async_reconnect(self) -> bool:
154+
"""Reconnect to the Modbus TCP server by closing and re-opening the connection."""
155+
async with self._request_lock:
156+
_LOGGER.info("Reconnecting to Modbus server at %s:%s", self.host, self.port)
157+
158+
try:
159+
try:
160+
await self.async_close()
161+
except Exception as e:
162+
_LOGGER.debug("Error closing Modbus client during reconnect: %s", e)
163+
164+
try:
165+
connected = await self.async_connect()
166+
except Exception as e:
167+
_LOGGER.warning(
168+
"Exception while reconnecting to Modbus server at %s:%s: %s",
169+
self.host,
170+
self.port,
171+
e,
172+
)
173+
return False
174+
175+
if connected:
176+
_LOGGER.info("Reconnected to Modbus server at %s:%s", self.host, self.port)
177+
else:
178+
_LOGGER.warning("Reconnect failed to Modbus server at %s:%s", self.host, self.port)
179+
180+
return connected
181+
except Exception as e:
182+
_LOGGER.warning("Unhandled exception during reconnect: %s", e)
183+
return False
184+
153185
async def async_read_register(
154186
self,
155187
register: int,
@@ -265,14 +297,15 @@ async def async_read_register(
265297
else:
266298
regs = result.registers
267299
_LOGGER.debug(
268-
"Requesting register %d (0x%04X) for sensor '%s' (type: %s, count: %s)",
300+
"Requesting register %d (0x%04X) from '%s' for sensor '%s' (type: %s, count: %s)",
269301
register,
270302
register,
303+
self.host,
271304
sensor_key or 'unknown',
272305
data_type,
273306
count,
274307
)
275-
_LOGGER.debug("Received data from register %d (0x%04X): %s", register, register, regs)
308+
_LOGGER.debug("Received data from '%s' for register %d (0x%04X): %s", self.host, register, register, regs)
276309

277310
if data_type == "int16":
278311
val = regs[0]

custom_components/marstek_modbus/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"domain": "marstek_modbus",
33
"name": "Marstek Venus Modbus",
4-
"version": "2026.1.1",
4+
"version": "2026.3.1",
55
"config_flow": true,
66
"documentation": "https://github.com/viperrnmc/marstek_venus_modbus",
77
"requirements": ["pymodbus>=3.9.2"],

custom_components/marstek_modbus/number.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ async def async_set_native_value(self, value: float) -> None:
148148
# Only refresh if write failed to get actual device state
149149
if not success:
150150
_LOGGER.debug("Write failed for %s, refreshing to get actual state", self._key)
151-
await self.coordinator.async_read_value(self.definition, self._key)
151+
await self.coordinator.async_read_value(self.definition, self._key, track_failure=False)
152152

153153
@property
154154
def device_info(self) -> dict:

0 commit comments

Comments
 (0)