55Unlike some vendors there is no separate "read once" settings space here, so
66every mapped register is polled each cycle. Contiguous registers are batched
77into 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"""
912from __future__ import annotations
1013import asyncio
14+ import inspect
1115import logging
12- from dataclasses import dataclass
16+ import time
17+ from dataclasses import dataclass , field
1318from datetime import timedelta
1419from typing import Any , Dict , List
1520
1924_LOGGER = logging .getLogger (__name__ )
2025
2126try :
22- from pymodbus .client import AsyncModbusTcpClient , AsyncModbusSerialClient
27+ from pymodbus .client import AsyncModbusTcpClient
2328except 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
2961class 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
5185class 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
0 commit comments