|
13 | 13 | # the GNU General Public License for more details. |
14 | 14 |
|
15 | 15 | # AUTHOR |
16 | | -# Marko Luther, 2023 |
| 16 | +# Marko Luther, 2026 |
17 | 17 |
|
| 18 | +import os |
| 19 | +import sys |
18 | 20 | import time |
19 | 21 | import logging |
| 22 | +import serial as pyserial |
20 | 23 | import asyncio |
| 24 | +import platform |
21 | 25 |
|
22 | 26 | from contextlib import suppress |
23 | 27 | from threading import Thread |
24 | | -from pymodbus.transport.serialtransport import create_serial_connection # patched pyserial-asyncio |
| 28 | +from pymodbus.transport.serialtransport import SerialTransport # patched pyserial-asyncio |
25 | 29 | from collections.abc import Callable, AsyncIterator |
26 | | -from typing import Final, TYPE_CHECKING |
| 30 | +from typing import Final, Any, TYPE_CHECKING |
27 | 31 |
|
28 | 32 |
|
29 | 33 | if TYPE_CHECKING: |
@@ -144,6 +148,107 @@ async def readuntil(self, separator:bytes = b'\n') -> bytes: |
144 | 148 | break |
145 | 149 | return res |
146 | 150 |
|
| 151 | + |
| 152 | +# clone from pymodbus/serialtransport.py extended by parameter 'do_not_open' (default False) which prevents opening the created serial port if set to True |
| 153 | +class ArtisanSerialTransport(SerialTransport): |
| 154 | + """An asyncio serial transport.""" |
| 155 | + |
| 156 | + force_poll: bool = os.name == 'nt' |
| 157 | + # async_loop: asyncio.AbstractEventLoop |
| 158 | + |
| 159 | + def __init__(self, # pylint: disable=super-init-not-called |
| 160 | + loop:asyncio.AbstractEventLoop, |
| 161 | + protocol:asyncio.Protocol, |
| 162 | + url:str, |
| 163 | + baudrate:int, |
| 164 | + bytesize:int, |
| 165 | + parity:str, |
| 166 | + stopbits:float, |
| 167 | + timeout:float, |
| 168 | + do_not_open:bool = False) -> None: |
| 169 | + """Initialize.""" |
| 170 | + # we call __init__ of async.Transport, but NOT that of pymodbus SerialTransport which would open the port |
| 171 | + asyncio.Transport.__init__(self) # pylint: disable=non-parent-init-called |
| 172 | + if 'serial' not in sys.modules: |
| 173 | + raise RuntimeError( |
| 174 | + 'Serial client requires pyserial ' |
| 175 | + 'Please install with "pip install pyserial" and try again.' |
| 176 | + ) |
| 177 | + self.async_loop = loop |
| 178 | + self.intern_protocol: asyncio.BaseProtocol = protocol |
| 179 | + self.sync_serial = pyserial.serial_for_url(url, exclusive=True, |
| 180 | + baudrate=baudrate, bytesize=bytesize, parity=parity, stopbits=stopbits, timeout=timeout, |
| 181 | + do_not_open=do_not_open) |
| 182 | + self.intern_write_buffer: list[bytes] = [] |
| 183 | + self.poll_task: asyncio.Task[Any] | None = None |
| 184 | + self._poll_wait_time = 0.0005 |
| 185 | + self.sync_serial.timeout = 0 |
| 186 | + self.sync_serial.write_timeout = 0 |
| 187 | + |
| 188 | + |
| 189 | +async def create_serial_connection( |
| 190 | + loop:asyncio.AbstractEventLoop, |
| 191 | + protocol_factory:Callable[[], asyncio.Protocol], |
| 192 | + url:str, |
| 193 | + baudrate:int, |
| 194 | + bytesize:int, |
| 195 | + parity:str, |
| 196 | + stopbits:float, |
| 197 | + timeout:float, |
| 198 | + clear_HUPCL:bool = False # if True, try to prevent toggling the RTS/DTR lines on opening the port to prevent to trigger a reboot on the connected device (ESP32/Orbiter) |
| 199 | +) -> tuple[asyncio.Transport, asyncio.BaseProtocol]: |
| 200 | + """Create a connection to a new serial port instance.""" |
| 201 | + protocol = protocol_factory() |
| 202 | + transport = ArtisanSerialTransport(loop, protocol, url, |
| 203 | + baudrate, |
| 204 | + bytesize, |
| 205 | + parity, |
| 206 | + stopbits, |
| 207 | + timeout, |
| 208 | + clear_HUPCL) # prevent opening the serial port on creation if clear_HUPCL is set |
| 209 | + #### |
| 210 | + |
| 211 | + # first we need to clear HUPCL, Hang Up on Close, (UNIX) or clear RTS/DTR (Windows) so the device will not reboot based on RTS and/or DTR |
| 212 | + # stty -F /dev/ttyACM0 -hupcl to clear the HUPCL (Hang Up on Close) flag, preventing the reset when the port closes or reopens. |
| 213 | + # see https://github.com/pyserial/pyserial/issues/124 |
| 214 | + # and https://github.com/npat-efault/picocom/blob/master/lowerrts.md |
| 215 | + if clear_HUPCL: |
| 216 | + # the transport serial port is not open yet in this case |
| 217 | + try: |
| 218 | + if platform.system() != 'Windows': |
| 219 | + import termios # pylint: disable=C0415,E0401 |
| 220 | + port:str = url.replace('/dev/tty.','/dev/cu.') |
| 221 | + # the following might hang on macOS for non-callup devices |
| 222 | + with open(port, encoding='utf8') as f: |
| 223 | + attrs = termios.tcgetattr(f) |
| 224 | + attrs[2] = attrs[2] & ~termios.HUPCL |
| 225 | + termios.tcsetattr(f, termios.TCSAFLUSH, attrs) |
| 226 | + f.close() |
| 227 | + time.sleep(0.1) |
| 228 | + except Exception as e: # pylint: disable=broad-except |
| 229 | + _log.error(e) |
| 230 | + try: |
| 231 | + # for Windows the following should be enough |
| 232 | + transport.sync_serial.dtr = False |
| 233 | + transport.sync_serial.rts = False |
| 234 | + except Exception as e: # pylint: disable=broad-except |
| 235 | + _log.error(e) |
| 236 | + |
| 237 | + # in any case we open the serial port |
| 238 | + if not transport.sync_serial.is_open: |
| 239 | + transport.sync_serial.open() # ty:ignore |
| 240 | + |
| 241 | + # and if clear_HUPCL, we immediately set the dtr/rts again |
| 242 | + if clear_HUPCL: |
| 243 | + try: |
| 244 | + transport.sync_serial.dtr = False |
| 245 | + transport.sync_serial.rts = False |
| 246 | + except Exception as e: # pylint: disable=broad-except |
| 247 | + _log.error(e) |
| 248 | + |
| 249 | + loop.call_soon(transport.setup) |
| 250 | + return transport, protocol |
| 251 | + |
147 | 252 | class AsyncComm: |
148 | 253 |
|
149 | 254 | __slots__ = [ '_asyncLoopThread', '_write_queue', '_running', '_host', '_port', '_serial', '_connected_handler', '_disconnected_handler', |
@@ -210,7 +315,7 @@ async def open_serial_connection(url:str, *, loop:asyncio.AbstractEventLoop|None |
210 | 315 | reader = asyncio.StreamReader(limit=limit, loop=loop) |
211 | 316 | protocol = asyncio.StreamReaderProtocol(reader, loop=loop) |
212 | 317 | transport, _ = await create_serial_connection( |
213 | | - loop, lambda: protocol, url, **kwargs |
| 318 | + loop, lambda: protocol, url, **kwargs # type: ignore[arg-type] # ty:ignore[unused-ignore-comment] |
214 | 319 | ) |
215 | 320 | writer = asyncio.StreamWriter(transport, protocol, reader, loop) |
216 | 321 | return reader, writer |
@@ -277,7 +382,8 @@ async def connect(self, connect_timeout:float=5) -> None: |
277 | 382 | bytesize = self._serial['bytesize'], |
278 | 383 | stopbits = self._serial['stopbits'], |
279 | 384 | parity = self._serial['parity'], |
280 | | - timeout = self._serial['timeout']) |
| 385 | + timeout = self._serial['timeout'], |
| 386 | + clear_HUPCL = self._serial['clear_HUPCL']) |
281 | 387 | else: |
282 | 388 | _log.debug('connecting to %s:%s ...', self._host, self._port) |
283 | 389 | connect = asyncio.open_connection(self._host, self._port) |
|
0 commit comments