From f6b0d21fe793547175b8469eea97bf4e151a685c Mon Sep 17 00:00:00 2001 From: iz3man Date: Sun, 27 Sep 2026 20:42:58 +0200 Subject: [PATCH] Use FC16 for single-register control writes (configurable), serialise writes with polling, surface write failures --- README.md | 4 + custom_components/deye_modbus/__init__.py | 6 +- custom_components/deye_modbus/config_flow.py | 5 + custom_components/deye_modbus/const.py | 11 ++ custom_components/deye_modbus/coordinator.py | 113 +++++++++--------- custom_components/deye_modbus/manifest.json | 2 +- custom_components/deye_modbus/number.py | 5 + custom_components/deye_modbus/select.py | 5 + custom_components/deye_modbus/services.yaml | 2 +- custom_components/deye_modbus/strings.json | 3 +- custom_components/deye_modbus/switch.py | 7 +- .../deye_modbus/translations/cs.json | 3 +- .../deye_modbus/translations/en.json | 3 +- 13 files changed, 103 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index 8fd749c..5da5db4 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,9 @@ Open the integration and click **Configure** to change (no restart needed — th - **Transport** — `tcp` (standard Modbus TCP) or `rtutcp` (RTU frame over a raw TCP socket, for some gateways). - **Address offset** — added to every register address, for gateways that shift the map. - **Serial parameters** — baud rate, byte size, parity (N/E/O), stop bits (only relevant for `rtutcp`). +- **Write mode** — Modbus function code used when a control entity (number / select / switch) writes a single register: + - `fc16` *(default)* — "Write Multiple Registers" with one value. This is what Deye's protocol document specifies, and many Deye inverters/loggers **silently ignore FC06**, which shows up as `No response received after 3 retries` and a value that never changes. + - `fc06` — "Write Single Register" (behaviour of v0.1.1 and earlier). Only use this if your gateway does not support FC16. --- @@ -157,6 +160,7 @@ data: - **No data / entities unavailable.** Double-check the **port** (`8899` for the Deye logger vs `502` for an RS485↔TCP gateway) and the **Modbus unit ID** (usually `1`). Confirm the IP is reachable from the HA host (`ping`), and that the logger firmware is recent. - **Only one Modbus master at a time.** The inverter/logger accepts a **single** Modbus connection. This integration will **conflict with the SolarMAN cloud logging and with any other Modbus poller** (a second HA integration, Node-RED, etc.). If the SolarMAN app / cloud is actively connected you may see dropouts — expect to give up cloud logging, or poll via a separate RS485 gateway. +- **Setting a control has no effect / write timeouts.** Make sure *Write mode* in Options is `fc16`. Failed writes are logged by `custom_components.deye_modbus.coordinator` and shown as an error in the UI. - **Intermittent timeouts.** Increase the **scan interval**, reduce competing connections, or improve WiFi signal to the logger. For flaky gateways try the `rtutcp` transport in Options. - **Wrong or shifted values.** If a gateway shifts the address map, set an **address offset** in Options. diff --git a/custom_components/deye_modbus/__init__.py b/custom_components/deye_modbus/__init__.py index 4bd1dc7..232e18f 100644 --- a/custom_components/deye_modbus/__init__.py +++ b/custom_components/deye_modbus/__init__.py @@ -13,7 +13,7 @@ CONF_TRANSPORT, DEFAULT_TRANSPORT, CONF_BAUDRATE, DEFAULT_BAUDRATE, CONF_BYTESIZE, DEFAULT_BYTESIZE, CONF_PARITY, DEFAULT_PARITY, CONF_STOPBITS, DEFAULT_STOPBITS, CONF_ADDR_OFFSET, DEFAULT_ADDR_OFFSET, - MIN_SCAN_SECONDS, MAX_SCAN_SECONDS, + MIN_SCAN_SECONDS, MAX_SCAN_SECONDS, CONF_WRITE_MODE, DEFAULT_WRITE_MODE, ) from .coordinator import DeyeModbusCoordinator, RegisterDef from .mapping import load_register_mapping @@ -83,6 +83,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: scan_interval = max(MIN_SCAN_SECONDS, min(MAX_SCAN_SECONDS, int(scan_interval))) transport = entry.options.get(CONF_TRANSPORT, entry.data.get(CONF_TRANSPORT, DEFAULT_TRANSPORT)) addr_offset = entry.options.get(CONF_ADDR_OFFSET, DEFAULT_ADDR_OFFSET) + write_mode = entry.options.get(CONF_WRITE_MODE, DEFAULT_WRITE_MODE) serial_params = { "baudrate": entry.options.get(CONF_BAUDRATE, DEFAULT_BAUDRATE), "bytesize": entry.options.get(CONF_BYTESIZE, DEFAULT_BYTESIZE), @@ -104,6 +105,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = DeyeModbusCoordinator( hass, host, port, unit_id, registers, scan_interval, transport=transport, serial_params=serial_params, address_offset=addr_offset, + write_mode=write_mode, ) hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = { @@ -114,7 +116,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) async def _svc_write_register(call: ServiceCall): - ok = await coordinator.write_single_register(int(call.data["address"]), int(call.data["value"])) + ok = await coordinator.write_register_fc06(int(call.data["address"]), int(call.data["value"])) _LOGGER.info("write_register -> %s", ok) async def _svc_write_registers(call: ServiceCall): diff --git a/custom_components/deye_modbus/config_flow.py b/custom_components/deye_modbus/config_flow.py index af185e4..9968291 100644 --- a/custom_components/deye_modbus/config_flow.py +++ b/custom_components/deye_modbus/config_flow.py @@ -20,6 +20,7 @@ CONF_BAUDRATE, DEFAULT_BAUDRATE, CONF_BYTESIZE, DEFAULT_BYTESIZE, CONF_PARITY, DEFAULT_PARITY, CONF_STOPBITS, DEFAULT_STOPBITS, CONF_ADDR_OFFSET, DEFAULT_ADDR_OFFSET, MIN_SCAN_SECONDS, MAX_SCAN_SECONDS, + CONF_WRITE_MODE, DEFAULT_WRITE_MODE, WRITE_MODES, ) from .models import model_options, get_model, DEFAULT_MODEL @@ -96,11 +97,15 @@ def _options_schema(self) -> vol.Schema: parity = str(self._entry_default(CONF_PARITY, DEFAULT_PARITY)).upper() if parity not in {"N", "E", "O"}: parity = DEFAULT_PARITY + write_mode = str(self._entry_default(CONF_WRITE_MODE, DEFAULT_WRITE_MODE)).lower() + if write_mode not in WRITE_MODES: + write_mode = DEFAULT_WRITE_MODE return vol.Schema({ vol.Optional(CONF_SCAN_INTERVAL, default=self._entry_int_default(CONF_SCAN_INTERVAL, DEFAULT_SCAN_SECONDS)): vol.All( vol.Coerce(int), vol.Range(min=MIN_SCAN_SECONDS, max=MAX_SCAN_SECONDS) ), vol.Optional(CONF_TRANSPORT, default=transport): vol.In(["tcp", "rtutcp"]), + vol.Optional(CONF_WRITE_MODE, default=write_mode): vol.In(WRITE_MODES), vol.Optional(CONF_ADDR_OFFSET, default=self._entry_int_default(CONF_ADDR_OFFSET, DEFAULT_ADDR_OFFSET)): vol.Coerce(int), vol.Optional(CONF_BAUDRATE, default=self._entry_int_default(CONF_BAUDRATE, DEFAULT_BAUDRATE)): vol.Coerce(int), vol.Optional(CONF_BYTESIZE, default=self._entry_int_default(CONF_BYTESIZE, DEFAULT_BYTESIZE)): vol.Coerce(int), diff --git a/custom_components/deye_modbus/const.py b/custom_components/deye_modbus/const.py index d24fb27..9f1ca7f 100644 --- a/custom_components/deye_modbus/const.py +++ b/custom_components/deye_modbus/const.py @@ -16,6 +16,7 @@ CONF_PARITY: Final = "parity" CONF_STOPBITS: Final = "stopbits" CONF_ADDR_OFFSET: Final = "address_offset" +CONF_WRITE_MODE: Final = "write_mode" # --- Defaults --- # Deye WiFi/LAN loggers expose Modbus TCP on 8899 by default. Third-party @@ -28,6 +29,16 @@ DEFAULT_TRANSPORT: Final = "tcp" DEFAULT_ADDR_OFFSET: Final = 0 +# Function code used when a control entity writes a single 16-bit register. +# Deye hybrid inverters (per the official Deye Modbus protocol document) only +# accept writes via FC16 "Write Multiple Registers"; FC06 requests are silently +# dropped by many firmware/logger combinations, which surfaces as a timeout. +# FC16 with a single register is a valid Modbus request, so it is the default. +WRITE_MODE_FC16: Final = "fc16" +WRITE_MODE_FC06: Final = "fc06" +WRITE_MODES: Final = [WRITE_MODE_FC16, WRITE_MODE_FC06] +DEFAULT_WRITE_MODE: Final = WRITE_MODE_FC16 + # Serial params (only used when transport == "rtutcp") DEFAULT_BAUDRATE: Final = 9600 DEFAULT_BYTESIZE: Final = 8 diff --git a/custom_components/deye_modbus/coordinator.py b/custom_components/deye_modbus/coordinator.py index 6e8031f..7f9d6f7 100644 --- a/custom_components/deye_modbus/coordinator.py +++ b/custom_components/deye_modbus/coordinator.py @@ -62,6 +62,7 @@ def __init__( transport: str = "tcp", serial_params: dict | None = None, address_offset: int = 0, + write_mode: str = "fc16", ) -> None: super().__init__( hass, _LOGGER, name="deye_modbus coordinator", @@ -74,6 +75,7 @@ def __init__( self._client = None self._lock = asyncio.Lock() self._addr_off = int(address_offset or 0) + self._write_mode = (write_mode or "fc16").lower() def _addr(self, addr: int) -> int: return int(addr) - self._addr_off if self._addr_off else int(addr) @@ -199,49 +201,64 @@ async def _read_holding(self, address, count): return None if rr is None or (getattr(rr, "isError", None) and rr.isError()) else getattr(rr, "registers", None) # ------------------------------------------------------------------ writes - async def write_single_register(self, address: int, value: int) -> bool: + async def _write_call(self, method_name: str, address: int, payload, what: str) -> bool: + """Run one Modbus write, serialised with polling on the shared client. + + All requests share a single TCP connection; without the lock a write can + interleave with a poll, which pymodbus reports as a transaction_id + mismatch and which can drop the write. Failures are logged instead of + being swallowed so that a rejected write is visible in the HA log. + """ a = self._addr(address) - client = await self._ensure_client() - wire = int(value) & 0xFFFF - for variant in ( - lambda: client.write_register(a, wire, slave=self._unit_id), - lambda: client.write_register(a, wire, self._unit_id), - lambda: client.write_register(address=a, value=wire, unit=self._unit_id), - lambda: client.write_register(a, wire), - ): - try: - rr = await variant() - if getattr(rr, "isError", lambda: False)(): + ok = False + async with self._lock: + client = await self._ensure_client() + method = getattr(client, method_name) + # pymodbus 3.x -> slave=, pymodbus 2.x -> unit=, older -> positional. + for variant in ( + lambda: method(a, payload, slave=self._unit_id), + lambda: method(a, payload, self._unit_id), + lambda: method(a, payload, unit=self._unit_id), + lambda: method(a, payload), + ): + try: + rr = await variant() + except TypeError: continue - await self.async_request_refresh() - return True - except TypeError: - continue - except Exception: # noqa: BLE001 - return False - return False + except Exception as err: # noqa: BLE001 + _LOGGER.warning("Modbus %s to register %s failed: %s", what, address, err) + return False + if getattr(rr, "isError", lambda: False)(): + _LOGGER.warning("Modbus %s to register %s rejected by inverter: %s", what, address, rr) + return False + ok = True + break + else: + _LOGGER.warning("Modbus %s to register %s: no compatible pymodbus call signature", what, address) + if ok: + _LOGGER.debug("Modbus %s to register %s OK: %s", what, address, payload) + await self.async_request_refresh() + return ok + + async def write_single_register(self, address: int, value: int) -> bool: + """Write one 16-bit holding register using the configured function code. + + With write_mode == "fc16" (default) this sends FC16 "Write Multiple + Registers" with a single value, which Deye inverters accept. With + "fc06" it sends FC06 "Write Single Register" (legacy behaviour). + """ + wire = int(value) & 0xFFFF + if self._write_mode == "fc06": + return await self.write_register_fc06(address, wire) + return await self._write_call("write_registers", address, [wire], "FC16 write") + + async def write_register_fc06(self, address: int, value: int) -> bool: + """Always use FC06, regardless of write_mode (for the raw service).""" + return await self._write_call("write_register", address, int(value) & 0xFFFF, "FC06 write") async def write_multiple_registers(self, address: int, values: list[int]) -> bool: - a = self._addr(address) - client = await self._ensure_client() vals = [int(v) & 0xFFFF for v in values] - for variant in ( - lambda: client.write_registers(a, vals, slave=self._unit_id), - lambda: client.write_registers(a, vals, self._unit_id), - lambda: client.write_registers(address=a, values=vals, unit=self._unit_id), - lambda: client.write_registers(a, vals), - ): - try: - rr = await variant() - if getattr(rr, "isError", lambda: False)(): - continue - await self.async_request_refresh() - return True - except TypeError: - continue - except Exception: # noqa: BLE001 - return False - return False + return await self._write_call("write_registers", address, vals, "FC16 write") async def write_u32(self, base_address: int, value: int, word_order: str = "high_low") -> bool: v = int(value) & 0xFFFFFFFF @@ -250,25 +267,7 @@ async def write_u32(self, base_address: int, value: int, word_order: str = "high return await self.write_multiple_registers(base_address, values) async def write_coil(self, address: int, value: int) -> bool: - a = self._addr(address) - client = await self._ensure_client() - for variant in ( - lambda: client.write_coil(a, bool(value), slave=self._unit_id), - lambda: client.write_coil(a, bool(value), self._unit_id), - lambda: client.write_coil(address=a, value=bool(value), unit=self._unit_id), - lambda: client.write_coil(a, bool(value)), - ): - try: - rr = await variant() - if getattr(rr, "isError", lambda: False)(): - continue - await self.async_request_refresh() - return True - except TypeError: - continue - except Exception: # noqa: BLE001 - return False - return False + return await self._write_call("write_coil", address, bool(value), "FC05 write") async def async_close(self): try: diff --git a/custom_components/deye_modbus/manifest.json b/custom_components/deye_modbus/manifest.json index 3aabcaa..d716af9 100644 --- a/custom_components/deye_modbus/manifest.json +++ b/custom_components/deye_modbus/manifest.json @@ -16,5 +16,5 @@ "requirements": [ "pymodbus>=3.5.0" ], - "version": "0.1.1" + "version": "0.1.2" } diff --git a/custom_components/deye_modbus/number.py b/custom_components/deye_modbus/number.py index cc04c42..92b4b06 100644 --- a/custom_components/deye_modbus/number.py +++ b/custom_components/deye_modbus/number.py @@ -5,6 +5,7 @@ from homeassistant.components.number import NumberEntity, NumberMode from homeassistant.config_entries import ConfigEntry +from homeassistant.exceptions import HomeAssistantError from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -77,6 +78,10 @@ async def async_set_native_value(self, value: float) -> None: if self._signed and raw < 0: raw &= 0xFFFF ok = await self._coordinator.write_single_register(self._address, raw) + if not ok: + raise HomeAssistantError( + f"Writing {self._attr_name} to the inverter failed (register {self._address}); see log" + ) if ok: self._value = v self.async_write_ha_state() diff --git a/custom_components/deye_modbus/select.py b/custom_components/deye_modbus/select.py index 4da90ed..3073802 100644 --- a/custom_components/deye_modbus/select.py +++ b/custom_components/deye_modbus/select.py @@ -5,6 +5,7 @@ from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry +from homeassistant.exceptions import HomeAssistantError from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -75,6 +76,10 @@ async def async_select_option(self, option: str) -> None: if option not in self._value_by_label: return ok = await self._coordinator.write_single_register(self._address, self._value_by_label[option]) + if not ok: + raise HomeAssistantError( + f"Writing {self._attr_name} to the inverter failed (register {self._address}); see log" + ) if ok: self._current_option = option self.async_write_ha_state() diff --git a/custom_components/deye_modbus/services.yaml b/custom_components/deye_modbus/services.yaml index fcd46be..bf43db5 100644 --- a/custom_components/deye_modbus/services.yaml +++ b/custom_components/deye_modbus/services.yaml @@ -1,6 +1,6 @@ write_register: name: Write single register (FC06) - description: Write one value to a holding register. + description: Write one value to a holding register, always using FC06 (ignores the write_mode option). Deye inverters usually require write_registers (FC16) instead. fields: address: name: Address diff --git a/custom_components/deye_modbus/strings.json b/custom_components/deye_modbus/strings.json index c3ffb3c..5cf9d03 100644 --- a/custom_components/deye_modbus/strings.json +++ b/custom_components/deye_modbus/strings.json @@ -29,7 +29,8 @@ "baudrate": "Baud rate (RTU over TCP)", "bytesize": "Byte size (RTU over TCP)", "parity": "Parity (RTU over TCP)", - "stopbits": "Stop bits (RTU over TCP)" + "stopbits": "Stop bits (RTU over TCP)", + "write_mode": "Write function code for controls (fc16 recommended for Deye / fc06 legacy)" } } } diff --git a/custom_components/deye_modbus/switch.py b/custom_components/deye_modbus/switch.py index 5673a92..6453ef4 100644 --- a/custom_components/deye_modbus/switch.py +++ b/custom_components/deye_modbus/switch.py @@ -5,6 +5,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry +from homeassistant.exceptions import HomeAssistantError from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -75,8 +76,10 @@ async def _write(self, value: int) -> bool: ok = await self._coordinator.write_coil(self._address, value) else: ok = await self._coordinator.write_single_register(self._address, value) - if ok: - await self._coordinator.async_request_refresh() + if not ok: + raise HomeAssistantError( + f"Writing {self._attr_name} to the inverter failed (register {self._address}); see log" + ) return ok def _sync_from_sensor(self) -> None: diff --git a/custom_components/deye_modbus/translations/cs.json b/custom_components/deye_modbus/translations/cs.json index 94922f0..cdfdfcd 100644 --- a/custom_components/deye_modbus/translations/cs.json +++ b/custom_components/deye_modbus/translations/cs.json @@ -29,7 +29,8 @@ "baudrate": "Baudrate (RTU přes TCP)", "bytesize": "Datové bity (RTU přes TCP)", "parity": "Parita (RTU přes TCP)", - "stopbits": "Stop bity (RTU přes TCP)" + "stopbits": "Stop bity (RTU přes TCP)", + "write_mode": "Funkční kód pro zápis ovládacích prvků (fc16 doporučeno pro Deye / fc06 starší)" } } } diff --git a/custom_components/deye_modbus/translations/en.json b/custom_components/deye_modbus/translations/en.json index c3ffb3c..5cf9d03 100644 --- a/custom_components/deye_modbus/translations/en.json +++ b/custom_components/deye_modbus/translations/en.json @@ -29,7 +29,8 @@ "baudrate": "Baud rate (RTU over TCP)", "bytesize": "Byte size (RTU over TCP)", "parity": "Parity (RTU over TCP)", - "stopbits": "Stop bits (RTU over TCP)" + "stopbits": "Stop bits (RTU over TCP)", + "write_mode": "Write function code for controls (fc16 recommended for Deye / fc06 legacy)" } } }