Skip to content

Commit d7a9040

Browse files
committed
- raise warning on missing bean information while connected plus only once
- minor BLE port improvements - introduce clear_HUPCL parameter to async serial communication which prevents toggling the dtr/rts lines which might trigger a reboot of the connected device (experimental) - lib updates
1 parent 8926eac commit d7a9040

19 files changed

Lines changed: 172 additions & 32 deletions

src/artisanlib/async_comm.py

Lines changed: 111 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,21 @@
1313
# the GNU General Public License for more details.
1414

1515
# AUTHOR
16-
# Marko Luther, 2023
16+
# Marko Luther, 2026
1717

18+
import os
19+
import sys
1820
import time
1921
import logging
22+
import serial as pyserial
2023
import asyncio
24+
import platform
2125

2226
from contextlib import suppress
2327
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
2529
from collections.abc import Callable, AsyncIterator
26-
from typing import Final, TYPE_CHECKING
30+
from typing import Final, Any, TYPE_CHECKING
2731

2832

2933
if TYPE_CHECKING:
@@ -144,6 +148,107 @@ async def readuntil(self, separator:bytes = b'\n') -> bytes:
144148
break
145149
return res
146150

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+
147252
class AsyncComm:
148253

149254
__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
210315
reader = asyncio.StreamReader(limit=limit, loop=loop)
211316
protocol = asyncio.StreamReaderProtocol(reader, loop=loop)
212317
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]
214319
)
215320
writer = asyncio.StreamWriter(transport, protocol, reader, loop)
216321
return reader, writer
@@ -277,7 +382,8 @@ async def connect(self, connect_timeout:float=5) -> None:
277382
bytesize = self._serial['bytesize'],
278383
stopbits = self._serial['stopbits'],
279384
parity = self._serial['parity'],
280-
timeout = self._serial['timeout'])
385+
timeout = self._serial['timeout'],
386+
clear_HUPCL = self._serial['clear_HUPCL'])
281387
else:
282388
_log.debug('connecting to %s:%s ...', self._host, self._port)
283389
connect = asyncio.open_connection(self._host, self._port)

src/artisanlib/atypes.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,7 @@ class SerialSettings(TypedDict):
540540
stopbits: int
541541
parity: str
542542
timeout: float
543+
clear_HUPCL: bool # 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
543544

544545
class BTBreakParams(TypedDict):
545546
delay: list[list[float]]

src/artisanlib/ble_port.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
import asyncio
1919
import logging
2020
from bleak import BleakScanner, BleakClient
21-
from bleak.exc import BleakCharacteristicNotFoundError, BleakBluetoothNotAvailableError
21+
from bleak.exc import BleakError, BleakCharacteristicNotFoundError, BleakBluetoothNotAvailableError
2222

2323

2424
from PyQt6.QtCore import QObject
@@ -200,6 +200,9 @@ def scan_and_connect(self,
200200
except BleakBluetoothNotAvailableError:
201201
_log.error('Bluetooth is not supported, turned off or permission is denied')
202202
return None, None, None
203+
except BleakError as e:
204+
_log.error('BLE exception: %s', e)
205+
return None, None, None
203206
except Exception: # pylint: disable=broad-except
204207
_log.error('exception in scan_and_connect: %s', fut.exception())
205208
return None, None, None
@@ -353,8 +356,11 @@ async def _connect(self, case_sensitive:bool=True, scan_timeout:float=6, connect
353356
self._disconnected_event.clear()
354357
await self._disconnected_event.wait()
355358
_log.debug('BLE reconnect')
356-
await asyncio.sleep(self._sleep_between_scans)
357-
self._sleep_between_scans = max(self._sleep_between_scans + self.SCAN_BETWEEN_SCANS_INC, self.SCAN_BETWEEN_SCANS_MAX)
359+
try:
360+
await asyncio.sleep(self._sleep_between_scans)
361+
self._sleep_between_scans = max(self._sleep_between_scans + self.SCAN_BETWEEN_SCANS_INC, self.SCAN_BETWEEN_SCANS_MAX)
362+
except Exception as e: # pylint: disable=broad-except
363+
_log.warning(e)
358364

359365
# release the async lock _disconnected_event after disconnect triggered to enable the automatic reconnect
360366
async def set_event(self) -> None:
@@ -370,7 +376,7 @@ def disconnected_callback(self, _client:BleakClient) -> None:
370376
if hasattr(self, '_async_loop_thread') and self._async_loop_thread is not None:
371377
asyncio.run_coroutine_threadsafe(self.set_event(), self._async_loop_thread.loop)
372378

373-
def send(self, message:bytes, response:bool = False, write_characteristic:str|None = None) -> None:
379+
def send(self, message:bytes, response:bool = False, write_characteristic:str|None = None, chunk:int = 20) -> None:
374380
if self._ble_client is not None and self._connected_service_uuid is not None and self._connected_service_uuid in self._writers:
375381
if self._logging:
376382
_log.debug('send to %s: %s', self._writers[self._connected_service_uuid], message)
@@ -388,7 +394,7 @@ def send(self, message:bytes, response:bool = False, write_characteristic:str|No
388394
else:
389395
_log.debug('send failed. Characteristic %s not registered for write for service %s', write_characteristic, self._connected_service_uuid)
390396
else:
391-
ble.write(self._ble_client, wc, message, response)
397+
ble.write(self._ble_client, wc, message, response, chunk)
392398

393399
def read(self, read_characteristic:str|None = None) -> bytes|None:
394400
if self._ble_client is not None and self._connected_service_uuid is not None and self._connected_service_uuid in self._readers:
@@ -439,6 +445,8 @@ def scan(self, scan_timeout:float = 5.0) -> 'list[tuple[BLEDevice, Advertisement
439445
return list(res.values())
440446
except BleakBluetoothNotAvailableError:
441447
_log.error('Bluetooth is not supported, turned off or permission is denied')
448+
except BleakError as e:
449+
_log.error('BLE exception: %s', e)
442450
except Exception as e: # pylint: disable=broad-except
443451
_log.exception(e)
444452
return []
@@ -471,7 +479,9 @@ def stop(self) -> None:
471479
if self._ble_client is None:
472480
ble.terminate_scan() # we stop ongoing scanning
473481
self._disconnect()
474-
self._async_loop_thread = None
482+
if self._async_loop_thread is not None:
483+
del self._async_loop_thread
484+
self._async_loop_thread = None
475485
self._ble_client = None
476486
self._connected_service_uuid = None
477487
self._connected_device_name = None

src/artisanlib/canvas.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1348,6 +1348,11 @@ def __init__(self, parent:QWidget, dpi:int, locale:str, aw:'ApplicationWindow')
13481348
self.roastpropertiesflag:int = 1 #resets roast properties if not zero
13491349
self.roastpropertiesAutoOpenFlag:int = 0 #open roast properties dialog on CHARGE if not zero
13501350
self.roastpropertiesAutoOpenDropFlag:int = 0 #open roast properties dialog on DROP if not zero
1351+
1352+
# if True and plus is connected reminds user to set beans and open Roast Properties dialog if not yet set (once)
1353+
# this flag is reset after the warning dialog popped up once and is set to True again on OFF and
1354+
self.plus_beans_reminder_on_start:bool = True
1355+
13511356
self.title:str = QApplication.translate('Scope Title', 'Roaster Scope')
13521357
self.title_show_always:bool = False
13531358
self.ambientTemp:float = 0.
@@ -13309,7 +13314,8 @@ def OnMonitor(self) -> None:
1330913314
bytesize = self.aw.ser.bytesize,
1331013315
stopbits = self.aw.ser.stopbits,
1331113316
parity = self.aw.ser.parity,
13312-
timeout = self.aw.ser.timeout)
13317+
timeout = self.aw.ser.timeout,
13318+
clear_HUPCL = False)
1331313319
self.aw.hottop = Hottop(
1331413320
serial=hottop_serial,
1331513321
connected_handler=lambda : self.aw.sendmessageSignal.emit(QApplication.translate('Message', '{} connected').format('Hottop'),True,None),
@@ -13327,7 +13333,8 @@ def OnMonitor(self) -> None:
1332713333
bytesize = self.aw.ser.bytesize,
1332813334
stopbits = self.aw.ser.stopbits,
1332913335
parity = self.aw.ser.parity,
13330-
timeout = self.aw.ser.timeout)
13336+
timeout = self.aw.ser.timeout,
13337+
clear_HUPCL = False)
1333113338
self.aw.santoker = Santoker(self.aw.santokerHost, self.aw.santokerPort,
1333213339
santoker_serial, self.aw.santokerBLE,
1333313340
connected_handler=lambda : self.aw.sendmessageSignal.emit(QApplication.translate('Message', '{} connected').format('Santoker'),True,None),
@@ -13370,7 +13377,8 @@ def OnMonitor(self) -> None:
1337013377
bytesize = self.aw.ser.bytesize,
1337113378
stopbits = self.aw.ser.stopbits,
1337213379
parity = self.aw.ser.parity,
13373-
timeout = self.aw.ser.timeout)
13380+
timeout = self.aw.ser.timeout,
13381+
clear_HUPCL = False)
1337413382
self.aw.kaleido.start(self.mode, self.aw.kaleidoHost, self.aw.kaleidoPort,
1337513383
serial=kaleido_serial,
1337613384
connected_handler=lambda : self.aw.sendmessageSignal.emit(QApplication.translate('Message', '{} connected').format('Kaleido'),True,None),
@@ -13406,7 +13414,8 @@ def OnMonitor(self) -> None:
1340613414
bytesize = self.aw.ser.bytesize,
1340713415
stopbits = self.aw.ser.stopbits,
1340813416
parity = self.aw.ser.parity,
13409-
timeout = self.aw.ser.timeout)
13417+
timeout = self.aw.ser.timeout,
13418+
clear_HUPCL = True)
1341013419
self.aw.orbiter = Orbiter(orbiter_serial,
1341113420
connected_handler=lambda : self.aw.sendmessageSignal.emit(QApplication.translate('Message', '{} connected').format('Orbiter'),True,None),
1341213421
disconnected_handler=lambda : self.aw.sendmessageSignal.emit(QApplication.translate('Message', '{} disconnected').format('Orbiter'),True,None))
@@ -13675,7 +13684,10 @@ def OffMonitor(self, respectAlwaysON:bool = True) -> None:
1367513684
_log.info('MODE: OFF MONITOR')
1367613685
if self.flagon:
1367713686
try:
13678-
# first activate "Stopping Mode" to ensure that sample() is not resetting the timer now (independent of the flagstart state)
13687+
# reset
13688+
self.plus_beans_reminder_on_start = True # ensure that for the next recording the corresponding warning is shown if beans are not specified for plus
13689+
13690+
# activate "Stopping Mode" to ensure that sample() is not resetting the timer now (independent of the flagstart state)
1367913691

1368013692
self.aw.buttonONOFF.setEnabled(False)
1368113693
ge:QGraphicsEffect|None = self.aw.buttonONOFF.graphicsEffect()
@@ -14363,6 +14375,7 @@ def ToggleRecorder(self, _:bool = False) -> None:
1436314375
if (self.aw.plus_account is not None and # plus connected
1436414376
not self.roastpropertiesAutoOpenFlag and # no "Open on CHARGE"
1436514377
not self.roastpropertiesAutoOpenDropFlag and # no "Open on DROP"
14378+
self.plus_beans_reminder_on_start and # warning was not yet shown for this recording
1436614379
(self.plus_coffee is None and self.plus_blend_spec is None and self.beans == '') and # beans are not set
1436714380
(self.aw.schedule_window is None or self.aw.schedule_window.selected_remaining_item is None) # scheduler is off or no schedule item selected
1436814381
):

src/artisanlib/colortrack.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,8 @@ def main() -> None:
205205
bytesize = 8,
206206
stopbits = 1,
207207
parity = 'N',
208-
timeout = 0.3)
208+
timeout = 0.3,
209+
clear_HUPCL = False)
209210
colorTrack = ColorTrack(serial=colortrack_serial)
210211
colorTrack.start()
211212
for _ in range(4):

src/artisanlib/comm.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1797,7 +1797,8 @@ def ColorTrackSerial(self) -> tuple[float,float,float]:
17971797
bytesize = self.bytesize,
17981798
stopbits = self.stopbits,
17991799
parity = self.parity,
1800-
timeout = self.timeout)
1800+
timeout = self.timeout,
1801+
clear_HUPCL = False)
18011802
self.colorTrackSerial = ColorTrack(serial=colortrack_serial)
18021803
self.colorTrackSerial.setLogging(self.aw.qmc.device_logging)
18031804
self.colorTrackSerial.start()

src/artisanlib/hottop.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,8 @@ def main() -> None:
210210
bytesize = 8,
211211
stopbits = 1,
212212
parity = 'N',
213-
timeout = 0.3)
213+
timeout = 0.3,
214+
clear_HUPCL = False)
214215
hottop = Hottop(serial=hottop_serial)
215216
hottop.start()
216217
for _ in range(4):

src/artisanlib/orbiter.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,8 @@ def main() -> None:
295295
bytesize = 8,
296296
stopbits = 1,
297297
parity = 'N',
298-
timeout = 0.5)
298+
timeout = 0.5,
299+
clear_HUPCL = True)
299300
orbiter = Orbiter(serial)
300301
orbiter.start()
301302
for _ in range(4):

src/artisanlib/roast_properties.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1686,6 +1686,7 @@ def __init__(self, parent:QWidget, aw:'ApplicationWindow', activeTab:int = 0, st
16861686
plus.util.setPlusIcon(mbox)
16871687
mbox.setStandardButtons(QMessageBox.StandardButton.Ok)
16881688
mbox.exec()
1689+
self.aw.qmc.plus_beans_reminder_on_start = False # prevent this warning to be shown again for this recording
16891690

16901691
self.setLayout(totallayout)
16911692

src/artisanlib/transposer.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -836,7 +836,7 @@ def calcTempPolyfit(self) -> 'npt.NDArray[numpy.float64]|None':
836836
@staticmethod
837837
def calcDiscretefits(sources:list[float|None], targets:list[float|None]) -> 'list[npt.NDArray[numpy.float64]|None]':
838838
if len(sources) != len(targets):
839-
return [None]*len(sources) # ty:ignore # Return type does not match returned value
839+
return [None]*len(sources)
840840
fits:list[npt.NDArray[numpy.float64]|None] = [None]*len(sources)
841841
last_fit:npt.NDArray[numpy.float64]|None = None
842842
for i, _ in enumerate(sources):

0 commit comments

Comments
 (0)