Skip to content

Commit 7be3c89

Browse files
committed
Fix settings writes: use FC16, correct pymodbus unit kwarg, surface errors
- Deye hybrids reject FC06; all register writes now go through FC16 - Detect device_id/slave/unit keyword from pymodbus signature (3.10+ renamed it) - Serialize writes with polling via the coordinator lock - Keep freshly written values for 30 s so a stale poll can't revert the UI - Raise HomeAssistantError when the inverter refuses a write - Fix double address-offset on reads; fix RTU-over-TCP client construction - Number entities show unknown instead of 0 without a read-back - Coordinator support for computed (sum) sensors
1 parent 591a24a commit 7be3c89

4 files changed

Lines changed: 178 additions & 108 deletions

File tree

‎custom_components/deye_modbus/coordinator.py‎

Lines changed: 147 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,16 @@
55
Unlike some vendors there is no separate "read once" settings space here, so
66
every mapped register is polled each cycle. Contiguous registers are batched
77
into single Modbus reads to keep the poll cheap.
8+
9+
Writes always use function code 0x10 (Write Multiple Registers): Deye
10+
inverters reject or silently ignore FC06 (Write Single Register).
811
"""
912
from __future__ import annotations
1013
import asyncio
14+
import inspect
1115
import logging
12-
from dataclasses import dataclass
16+
import time
17+
from dataclasses import dataclass, field
1318
from datetime import timedelta
1419
from typing import Any, Dict, List
1520

@@ -19,11 +24,38 @@
1924
_LOGGER = logging.getLogger(__name__)
2025

2126
try:
22-
from pymodbus.client import AsyncModbusTcpClient, AsyncModbusSerialClient
27+
from pymodbus.client import AsyncModbusTcpClient
2328
except Exception as exc: # pragma: no cover
2429
_LOGGER.error("pymodbus import failed: %s", exc)
2530
raise
2631

32+
try: # pymodbus >= 3.7
33+
from pymodbus import FramerType as _FramerType
34+
_RTU_FRAMER: Any = _FramerType.RTU
35+
except Exception: # noqa: BLE001
36+
try:
37+
from pymodbus.framer import ModbusRtuFramer as _RTU_FRAMER # type: ignore
38+
except Exception: # noqa: BLE001
39+
_RTU_FRAMER = None
40+
41+
# How long a freshly written value is trusted over a stale poll result.
42+
PENDING_WRITE_SECONDS = 30
43+
44+
45+
def _unit_kw(method) -> str | None:
46+
"""Return the keyword pymodbus uses for the Modbus unit on this version.
47+
48+
pymodbus >= 3.10: device_id, 3.0-3.9: slave, 2.x: unit.
49+
"""
50+
try:
51+
params = inspect.signature(method).parameters
52+
except (TypeError, ValueError):
53+
return None
54+
for kw in ("device_id", "slave", "unit"):
55+
if kw in params:
56+
return kw
57+
return None
58+
2759

2860
@dataclass
2961
class RegisterDef:
@@ -46,6 +78,8 @@ class RegisterDef:
4678
icon: str | None = None
4779
enabled_default: bool = True # entity_registry_enabled_default
4880
precision: int | None = None # suggested_display_precision (decimals)
81+
compute: str | None = None # derived value: "sum" (no Modbus read)
82+
sources: list[str] = field(default_factory=list) # unique_ids feeding `compute`
4983

5084

5185
class DeyeModbusCoordinator(DataUpdateCoordinator[dict[str, Any]]):
@@ -74,51 +108,73 @@ def __init__(
74108
self._client = None
75109
self._lock = asyncio.Lock()
76110
self._addr_off = int(address_offset or 0)
111+
self._computed = [r for r in registers if r.compute]
112+
self._polled = [r for r in registers if not r.compute]
113+
# address -> (raw word, expiry) for values we just wrote
114+
self._pending: dict[int, tuple[int, float]] = {}
77115

78116
def _addr(self, addr: int) -> int:
79117
return int(addr) - self._addr_off if self._addr_off else int(addr)
80118

81119
# ------------------------------------------------------------------ client
82120
async def _ensure_client(self):
83121
if self._client is None:
122+
kwargs: dict[str, Any] = {"port": self._port, "timeout": 5}
84123
if self._transport == "rtutcp":
85-
url = f"socket://{self._host}:{self._port}"
86-
params = {
87-
"method": "rtu", "port": url,
88-
"baudrate": int(self._serial_params.get("baudrate", 9600)),
89-
"bytesize": int(self._serial_params.get("bytesize", 8)),
90-
"parity": str(self._serial_params.get("parity", "N")),
91-
"stopbits": int(self._serial_params.get("stopbits", 1)),
92-
"timeout": 5,
93-
}
94-
self._client = AsyncModbusSerialClient(**params)
95-
else:
96-
try:
97-
self._client = AsyncModbusTcpClient(self._host, port=self._port, timeout=5)
98-
except TypeError:
99-
self._client = AsyncModbusTcpClient(self._host, port=self._port)
124+
# Modbus RTU frames tunnelled over a raw TCP socket.
125+
if _RTU_FRAMER is None:
126+
raise UpdateFailed("This pymodbus version has no RTU framer")
127+
kwargs["framer"] = _RTU_FRAMER
128+
self._client = AsyncModbusTcpClient(self._host, **kwargs)
100129
if not bool(getattr(self._client, "connected", False)):
101130
try:
102131
await self._client.connect()
103132
except Exception as e: # noqa: BLE001
104133
raise UpdateFailed(f"Modbus connect failed: {e}") from e
134+
if not bool(getattr(self._client, "connected", False)):
135+
raise UpdateFailed(f"Modbus connect to {self._host}:{self._port} failed")
105136
return self._client
106137

138+
def _unit_kwargs(self, method) -> dict[str, int]:
139+
kw = _unit_kw(method)
140+
return {kw: self._unit_id} if kw else {}
141+
107142
# ------------------------------------------------------------------ polling
108143
async def _async_update_data(self) -> dict[str, Any]:
109144
async with self._lock:
110145
try:
111146
result: dict[str, Any] = {}
112-
inputs = [r for r in self._registers if r.register_type == "input"]
113-
holdings = [r for r in self._registers if r.register_type != "input"]
147+
inputs = [r for r in self._polled if r.register_type == "input"]
148+
holdings = [r for r in self._polled if r.register_type != "input"]
114149
if holdings:
115150
await self._read_grouped(holdings, result, self._read_holding)
116151
if inputs:
117152
await self._read_grouped(inputs, result, self._read_input)
153+
self._apply_computed(result)
118154
return result
155+
except UpdateFailed:
156+
raise
119157
except Exception as err: # noqa: BLE001
158+
await self._reset_client()
120159
raise UpdateFailed(err) from err
121160

161+
def _apply_computed(self, out: dict[str, Any]) -> None:
162+
for r in self._computed:
163+
if r.compute == "sum":
164+
vals = [out.get(src) for src in r.sources]
165+
vals = [v for v in vals if v is not None]
166+
out[r.unique_id] = (sum(vals) * r.scale) if vals else None
167+
else:
168+
out[r.unique_id] = None
169+
170+
async def _reset_client(self) -> None:
171+
if self._client is not None:
172+
try:
173+
self._client.close()
174+
except Exception: # noqa: BLE001
175+
pass
176+
self._client = None
177+
122178
async def _read_grouped(self, regs: list[RegisterDef], out: dict[str, Any], fn):
123179
regs = sorted(regs, key=lambda r: r.address)
124180
GAP = 8 # merge registers within this many words into one read
@@ -139,7 +195,20 @@ async def _read_grouped(self, regs: list[RegisterDef], out: dict[str, Any], fn):
139195
await self._read_window(fn, out, start, end, acc)
140196

141197
async def _read_window(self, fn, out, start, end, regs):
142-
raw = await fn(self._addr(start), end - start)
198+
raw = await fn(start, end - start)
199+
if raw is None:
200+
_LOGGER.debug("Read of registers %s-%s failed", start, end - 1)
201+
elif fn == self._read_holding and self._pending:
202+
raw = list(raw)
203+
now = time.monotonic()
204+
for addr, (val, expiry) in list(self._pending.items()):
205+
if not start <= addr < end:
206+
continue
207+
idx = addr - start
208+
if raw[idx] == val or now > expiry:
209+
self._pending.pop(addr, None) # inverter caught up (or gave up)
210+
else:
211+
raw[idx] = val # stale poll - keep what we wrote
143212
for r in regs:
144213
out[r.unique_id] = self._decode(r, raw, start)
145214

@@ -175,19 +244,10 @@ async def _call_read(self, method_name, address, count):
175244
client = await self._ensure_client()
176245
method = getattr(client, method_name)
177246
a = self._addr(address)
178-
# pymodbus 3.x -> slave=, pymodbus 2.x -> unit=, older -> positional.
179-
for kwargs in (
180-
{"count": count, "slave": self._unit_id},
181-
{"count": count, "unit": self._unit_id},
182-
{"count": count},
183-
):
184-
try:
185-
return await method(a, **kwargs)
186-
except TypeError:
187-
continue
188247
try:
189-
return await method(a, count)
190-
except TypeError:
248+
return await method(a, count=count, **self._unit_kwargs(method))
249+
except Exception as err: # noqa: BLE001
250+
_LOGGER.debug("%s(%s, %s) raised %s", method_name, a, count, err)
191251
return None
192252

193253
async def _read_input(self, address, count):
@@ -199,49 +259,52 @@ async def _read_holding(self, address, count):
199259
return None if rr is None or (getattr(rr, "isError", None) and rr.isError()) else getattr(rr, "registers", None)
200260

201261
# ------------------------------------------------------------------ writes
202-
async def write_single_register(self, address: int, value: int) -> bool:
262+
async def write_multiple_registers(self, address: int, values: list[int]) -> bool:
263+
"""Write with FC16 - the only write function Deye inverters accept."""
264+
vals = [int(v) & 0xFFFF for v in values]
203265
a = self._addr(address)
204-
client = await self._ensure_client()
205-
wire = int(value) & 0xFFFF
206-
for variant in (
207-
lambda: client.write_register(a, wire, slave=self._unit_id),
208-
lambda: client.write_register(a, wire, self._unit_id),
209-
lambda: client.write_register(address=a, value=wire, unit=self._unit_id),
210-
lambda: client.write_register(a, wire),
211-
):
266+
async with self._lock:
212267
try:
213-
rr = await variant()
214-
if getattr(rr, "isError", lambda: False)():
215-
continue
216-
await self.async_request_refresh()
217-
return True
218-
except TypeError:
219-
continue
220-
except Exception: # noqa: BLE001
268+
client = await self._ensure_client()
269+
method = client.write_registers
270+
rr = await method(a, vals, **self._unit_kwargs(method))
271+
except Exception as err: # noqa: BLE001
272+
_LOGGER.error("Write %s to register %s failed: %s", vals, address, err)
273+
await self._reset_client()
274+
return False
275+
if rr is None or (getattr(rr, "isError", None) and rr.isError()):
276+
_LOGGER.error("Inverter rejected write %s to register %s: %s", vals, address, rr)
221277
return False
222-
return False
278+
expiry = time.monotonic() + PENDING_WRITE_SECONDS
279+
for i, v in enumerate(vals):
280+
self._pending[address + i] = (v, expiry)
281+
_LOGGER.debug("Wrote %s to register %s", vals, address)
282+
self._patch_data(address, vals)
283+
await self.async_request_refresh()
284+
return True
223285

224-
async def write_multiple_registers(self, address: int, values: list[int]) -> bool:
225-
a = self._addr(address)
226-
client = await self._ensure_client()
227-
vals = [int(v) & 0xFFFF for v in values]
228-
for variant in (
229-
lambda: client.write_registers(a, vals, slave=self._unit_id),
230-
lambda: client.write_registers(a, vals, self._unit_id),
231-
lambda: client.write_registers(address=a, values=vals, unit=self._unit_id),
232-
lambda: client.write_registers(a, vals),
233-
):
234-
try:
235-
rr = await variant()
236-
if getattr(rr, "isError", lambda: False)():
237-
continue
238-
await self.async_request_refresh()
239-
return True
240-
except TypeError:
286+
async def write_single_register(self, address: int, value: int) -> bool:
287+
# Deliberately FC16 with one value, not FC06.
288+
return await self.write_multiple_registers(address, [value])
289+
290+
def _patch_data(self, address: int, vals: list[int]) -> None:
291+
"""Reflect a successful write in coordinator data immediately."""
292+
if not self.data:
293+
return
294+
span = range(address, address + len(vals))
295+
data = dict(self.data)
296+
changed = False
297+
for r in self._polled:
298+
if r.register_type == "input" or r.address not in span:
241299
continue
242-
except Exception: # noqa: BLE001
243-
return False
244-
return False
300+
if r.address + r.count - 1 not in span:
301+
continue
302+
words = vals[r.address - address:r.address - address + r.count]
303+
data[r.unique_id] = self._decode(r, words, r.address)
304+
changed = True
305+
if changed:
306+
self._apply_computed(data)
307+
self.async_set_updated_data(data)
245308

246309
async def write_u32(self, base_address: int, value: int, word_order: str = "high_low") -> bool:
247310
v = int(value) & 0xFFFFFFFF
@@ -251,28 +314,25 @@ async def write_u32(self, base_address: int, value: int, word_order: str = "high
251314

252315
async def write_coil(self, address: int, value: int) -> bool:
253316
a = self._addr(address)
254-
client = await self._ensure_client()
255-
for variant in (
256-
lambda: client.write_coil(a, bool(value), slave=self._unit_id),
257-
lambda: client.write_coil(a, bool(value), self._unit_id),
258-
lambda: client.write_coil(address=a, value=bool(value), unit=self._unit_id),
259-
lambda: client.write_coil(a, bool(value)),
260-
):
317+
async with self._lock:
261318
try:
262-
rr = await variant()
263-
if getattr(rr, "isError", lambda: False)():
264-
continue
265-
await self.async_request_refresh()
266-
return True
267-
except TypeError:
268-
continue
269-
except Exception: # noqa: BLE001
319+
client = await self._ensure_client()
320+
method = client.write_coil
321+
rr = await method(a, bool(value), **self._unit_kwargs(method))
322+
except Exception as err: # noqa: BLE001
323+
_LOGGER.error("Write coil %s failed: %s", address, err)
270324
return False
271-
return False
325+
if rr is None or (getattr(rr, "isError", None) and rr.isError()):
326+
_LOGGER.error("Inverter rejected coil write %s: %s", address, rr)
327+
return False
328+
await self.async_request_refresh()
329+
return True
272330

273331
async def async_close(self):
274332
try:
275333
if self._client:
276-
await self._client.close()
334+
res = self._client.close()
335+
if inspect.isawaitable(res):
336+
await res
277337
except Exception: # noqa: BLE001
278338
pass

‎custom_components/deye_modbus/number.py‎

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from homeassistant.components.number import NumberEntity, NumberMode
77
from homeassistant.config_entries import ConfigEntry
88
from homeassistant.core import HomeAssistant
9+
from homeassistant.exceptions import HomeAssistantError
910
from homeassistant.helpers.entity import EntityCategory
1011
from homeassistant.helpers.entity_platform import AddEntitiesCallback
1112
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -62,11 +63,11 @@ def __init__(self, coordinator: DeyeModbusCoordinator, entry: ConfigEntry, cfg:
6263
self._signed = bool(cfg.get("signed", False))
6364
self._read_uid: Optional[str] = cfg.get("read_unique_id")
6465
self._read_factor = float(cfg.get("read_factor", 1.0))
65-
self._value: float = max(self._attr_native_min_value, min(self._attr_native_max_value, 0.0))
66+
self._value: float | None = None
6667
self._sync_from_sensor()
6768

6869
@property
69-
def native_value(self) -> float:
70+
def native_value(self) -> float | None:
7071
return self._value
7172

7273
async def async_set_native_value(self, value: float) -> None:
@@ -77,10 +78,10 @@ async def async_set_native_value(self, value: float) -> None:
7778
if self._signed and raw < 0:
7879
raw &= 0xFFFF
7980
ok = await self._coordinator.write_single_register(self._address, raw)
80-
if ok:
81-
self._value = v
82-
self.async_write_ha_state()
83-
await self._coordinator.async_request_refresh()
81+
if not ok:
82+
raise HomeAssistantError(f"Deye inverter did not accept {self.name} = {v}")
83+
self._value = v
84+
self.async_write_ha_state()
8485

8586
def _sync_from_sensor(self) -> None:
8687
if not self._read_uid:
@@ -131,7 +132,14 @@ def native_value(self) -> float:
131132
async def async_set_native_value(self, value: float) -> None:
132133
v = int(max(self._attr_native_min_value, min(self._attr_native_max_value, round(value))))
133134
ok = await self._coordinator.write_u32(self._base, v, self._order)
134-
if ok:
135-
self._value = float(v)
136-
self.async_write_ha_state()
137-
await self._coordinator.async_request_refresh()
135+
if not ok:
136+
raise HomeAssistantError(f"Deye inverter did not accept {self.name} = {v}")
137+
self._value = float(v)
138+
self.async_write_ha_state()
139+
140+
def _handle_coordinator_update(self) -> None:
141+
rb = self._cfg.get("read_unique_id")
142+
raw = (self.coordinator.data or {}).get(rb) if rb else None
143+
if raw is not None:
144+
self._value = float(raw)
145+
self.async_write_ha_state()

0 commit comments

Comments
 (0)