Skip to content

Commit ad35ac3

Browse files
keep the machine connection alive across system standby
A system standby tears down the USB/Bluetooth link to the machine. Machines watching the communication (like Kaleido) react on that connection loss, on a laptop typically towards the end of a roast when the user did not touch the keyboard for a while. - adds artisanlib/power.py with a SleepInhibitor preventing the idle system sleep while Artisan is ON (macOS: NSProcessInfo activity, which also disables App Nap and the timer coalescing that delays sampling while the display is off, with an IOKit power assertion as fallback; Windows: SetThreadExecutionState; Linux: systemd-inhibit). The display is still allowed to sleep. - adds the 'Prevent Sleep' flag (default ON) to the Sampling dialog - adds a WakeDetector recognizing system suspensions that cannot be inhibited (eg. a laptop closing its lid) and reconnects the machine on wake via the new AsyncComm.reconnect()/KaleidoPort.reconnect(), both sharing the new force_reconnect(), as a connection established before a suspension can be dead without the transport ever reporting an error - reports a lost connection only once instead of on every failing reconnect - logs the connection lifecycle (connect, connected, connection lost, timeout, serial exception) on the default log level to ease the analysis of connection issues Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 58be935 commit ad35ac3

11 files changed

Lines changed: 1044 additions & 27 deletions

File tree

src/artisanlib/async_comm.py

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,23 @@ def loop(self) -> asyncio.AbstractEventLoop:
8686
return self.__loop
8787

8888

89+
# closes the given connection through the asyncio loop it is running in, to have the connect loop
90+
# of the corresponding transport re-establish it immediately.
91+
# Used on system wake as a connection that was established before a system suspension might be dead
92+
# without the transport ever reporting an error or EOF (thus without ever timing out).
93+
# Can be called from any thread. Returns True if a reconnect was triggered.
94+
def force_reconnect(loop_thread:AsyncLoopThread|None, writer:asyncio.StreamWriter|None) -> bool:
95+
if loop_thread is None or writer is None:
96+
return False
97+
_log.info('reconnect requested')
98+
try:
99+
loop_thread.loop.call_soon_threadsafe(writer.close)
100+
return True
101+
except Exception as e: # pylint: disable=broad-except
102+
_log.error(e)
103+
return False
104+
105+
89106
class AsyncIterable:
90107

91108
_queue: 'asyncio.Queue[bytes]' # type Queue is not subscriptable in Python <3.9 thus it is quoted
@@ -263,7 +280,7 @@ async def create_serial_connection(
263280

264281
class AsyncComm:
265282

266-
__slots__ = [ '_asyncLoopThread', '_write_queue', '_running', '_serialize_write_lock', '_ACK_received', '_write_errors_without_disconnect', 'write_error_sem',
283+
__slots__ = [ '_asyncLoopThread', '_write_queue', '_running', '_writer', '_serialize_write_lock', '_ACK_received', '_write_errors_without_disconnect', 'write_error_sem',
267284
'_host', '_port', '_serial', '_connected_handler', '_disconnected_handler',
268285
'_verify_crc', '_logging', '_send_timeout' ]
269286

@@ -275,6 +292,7 @@ def __init__(self, host:str = '127.0.0.1', port:int = 8080, serial:'SerialSettin
275292
self._asyncLoopThread: AsyncLoopThread|None = None # the asyncio AsyncLoopThread object
276293
self._write_queue: asyncio.Queue[bytes]|None = None # noqa: UP037 # quotes for Python3.8 # the write_queue
277294
self._running:bool = False # while true we keep running the thread
295+
self._writer: asyncio.StreamWriter|None = None # the writer of the currently established connection (if any)
278296

279297
# lock to serialize write_await calls to realize request/response patterns in send_await/write_await
280298
self._serialize_write_lock:asyncio.Lock = asyncio.Lock()
@@ -406,7 +424,7 @@ async def connect(self, connect_timeout:float=5) -> None:
406424
while self._running:
407425
try:
408426
if self._serial is not None:
409-
_log.debug('connecting to serial port: %s ...', self._serial['port'])
427+
_log.info('connecting to serial port: %s ...', self._serial['port'])
410428
connect = self.open_serial_connection(
411429
url = self._serial['port'],
412430
baudrate = self._serial['baudrate'],
@@ -416,24 +434,25 @@ async def connect(self, connect_timeout:float=5) -> None:
416434
timeout = self._serial['timeout'],
417435
clear_HUPCL = self._serial['clear_HUPCL'])
418436
else:
419-
_log.debug('connecting to %s:%s ...', self._host, self._port)
437+
_log.info('connecting to %s:%s ...', self._host, self._port)
420438
connect = asyncio.open_connection(self._host, self._port)
421439
# Wait for 2 seconds, then raise TimeoutError
422440
reader, writer = await asyncio.wait_for(connect, timeout=connect_timeout)
423441
if writer is not None: # pyright:ignore[reportUnnecessaryComparison] # reader is of type asyncio.streams.StreamReader and thus never None
424442
self._write_queue = asyncio.Queue()
443+
self._writer = writer # registered to allow a forced reconnect (see reconnect())
425444
write_handler = asyncio.create_task(self.handle_writes(writer, self._write_queue))
426445
read_handler = asyncio.create_task(self.handle_reads(reader))
427446
self._ACK_received = asyncio.Event()
428-
_log.debug('connected')
447+
_log.info('connected to %s', self._serial['port'] if self._serial is not None else f'{self._host}:{self._port}')
429448
was_connected = True
430449
if self._connected_handler is not None:
431450
try:
432451
self._connected_handler()
433452
except Exception as e: # pylint: disable=broad-except
434453
_log.exception(e)
435454
done, pending = await asyncio.wait([read_handler, write_handler], return_when=asyncio.FIRST_COMPLETED)
436-
_log.debug('disconnected')
455+
_log.warning('connection lost')
437456

438457
for task in pending:
439458
task.cancel()
@@ -452,20 +471,24 @@ async def connect(self, connect_timeout:float=5) -> None:
452471
self._ACK_received = None
453472

454473
except TimeoutError:
455-
_log.debug('connection timeout')
456-
except SerialException:
457-
#_log.debug('serial exception: %s',e)
458-
pass
474+
_log.warning('connection timeout')
475+
except SerialException as e:
476+
_log.warning('serial exception: %s', e)
459477
except Exception as e: # pylint: disable=broad-except
460478
_log.error('exception 1: %s', e)
461479
finally:
462480
self._ACK_received = None
481+
self._writer = None
463482
self.reset_readings()
464-
if was_connected and self._disconnected_handler is not None:
465-
try:
466-
self._disconnected_handler()
467-
except Exception as e: # pylint: disable=broad-except
468-
_log.error('exception 2: %s', e)
483+
if was_connected:
484+
# only report a disconnect if a connection was established before to not
485+
# repeat the disconnect message on every failing reconnect attempt
486+
was_connected = False
487+
if self._disconnected_handler is not None:
488+
try:
489+
self._disconnected_handler()
490+
except Exception as e: # pylint: disable=broad-except
491+
_log.error('exception 2: %s', e)
469492
if writer is not None:
470493
try:
471494
writer.close()
@@ -476,6 +499,11 @@ async def connect(self, connect_timeout:float=5) -> None:
476499
_log.error('exception 3: %s', e)
477500
await asyncio.sleep(1)
478501

502+
# closes the current connection to have the connect loop re-establish it immediately
503+
# (see force_reconnect())
504+
def reconnect(self) -> bool:
505+
return self._running and force_reconnect(self._asyncLoopThread, self._writer)
506+
479507
def send(self, message:bytes) -> None:
480508
if self._asyncLoopThread is not None and self._write_queue is not None:
481509
asyncio.run_coroutine_threadsafe(self._write_queue.put(message), self._asyncLoopThread.loop)

src/artisanlib/canvas.py

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@
5454

5555
if TYPE_CHECKING:
5656
from artisanlib.comm import serialport # pylint: disable=unused-import
57+
from artisanlib.power import SleepInhibitor, WakeDetector # pylint: disable=unused-import
58+
from artisanlib.async_comm import AsyncComm # pylint: disable=unused-import
59+
from artisanlib.kaleido import KaleidoPort # pylint: disable=unused-import
5760
from artisanlib.atypes import ProfileData, BTU # pylint: disable=unused-import
5861
from artisanlib.main import ApplicationWindow # pylint: disable=unused-import
5962
from plus.stock import Blend # pylint: disable=unused-import
@@ -126,6 +129,9 @@
126129

127130
_log: Final[logging.Logger] = logging.getLogger(__name__)
128131

132+
# interval in milliseconds in which Artisan checks for a system suspension while sampling
133+
_wake_check_interval: Final[int] = 5000
134+
129135

130136

131137
type Interp1dKind = Literal['linear', 'nearest', 'nearest-up', 'zero', 'slinear', 'quadratic', 'cubic', 'previous', 'next']
@@ -287,7 +293,7 @@ class tgraphcanvas(QObject):
287293
'extraname1', 'extraname2', 'extramathexpression1', 'extramathexpression2', 'extralinestyles1', 'extralinestyles2', 'extradrawstyles1', 'extradrawstyles2',
288294
'extralinewidths1', 'extralinewidths2', 'extramarkers1', 'extramarkers2', 'extramarkersizes1', 'extramarkersizes2', 'devicetablecolumnwidths', 'energytablecolumnwidths', 'extraNoneTempHint1',
289295
'extraNoneTempHint2', 'plotcurves', 'plotcurvecolor', 'overlapList', 'tight_layout_params', 'cupping_tight_layout_params', 'fig', 'ax', 'delta_ax', 'legendloc', 'legendloc_pos', 'onclick_cid',
290-
'oncpick_cid', 'ondraw_cid', 'onmove_cid', 'rateofchange1', 'rateofchange2', 'flagon', 'flagstart', 'flagKeepON', 'flagOpenCompleted', 'flagsampling', 'flagsamplingthreadrunning',
296+
'oncpick_cid', 'ondraw_cid', 'onmove_cid', 'rateofchange1', 'rateofchange2', 'flagon', 'flagstart', 'flagKeepON', 'flagOpenCompleted', 'flagKeepAwake', 'sleep_inhibitor', 'wake_detector', 'wake_timer', 'flagsampling', 'flagsamplingthreadrunning',
291297
'manuallogETflag', 'zoom_follow', 'zoom_follow_onET', 'alignEvent', 'compareAlignEvent', 'compareEvents', 'compareET', 'compareBT', 'compareDeltaET', 'compareDeltaBT', 'compareMainEvents', 'compareBBP', 'compareRoast', 'compareExtraCurves1', 'compareExtraCurves2',
292298
'replayType', 'replayDropType', 'replayedBackgroundEvents', 'beepedBackgroundEvents', 'roastpropertiesflag', 'roastpropertiesAutoOpenFlag', 'roastpropertiesAutoOpenDropFlag',
293299
'title', 'title_show_always', 'ambientTemp', 'ambientTempSource', 'ambientHumiditySource', 'ambientPressureSource', 'ambient_temperature_device', 'ambient_pressure', 'ambient_pressure_device', 'ambient_humidity',
@@ -923,6 +929,10 @@ def __init__(self, parent:QWidget, dpi:int, locale:str, aw:'ApplicationWindow')
923929
self.flagstart:bool = False # Artisan logging/recording
924930
self.flagKeepON:bool = False # turn Artisan ON again after pressing OFF during recording
925931
self.flagOpenCompleted:bool = False # after completing a recording with OFF, send the saved profile to be opened in the ArtisanViewer
932+
self.flagKeepAwake:bool = True # if True the computer is prevented from entering standby (idle system sleep) while Artisan is ON
933+
self.sleep_inhibitor:SleepInhibitor|None = None # holds the system sleep inhibition while Artisan is ON (see flagKeepAwake)
934+
self.wake_detector:WakeDetector|None = None # detects system suspensions while Artisan is ON to reconnect the machine on wake
935+
self.wake_timer:QTimer|None = None # drives the wake_detector while Artisan is ON
926936
self.flagsampling:bool = False # if True, Artisan is still in the sampling phase and one has to wait for its end to turn OFF
927937
self.flagsamplingthreadrunning:bool = False
928938
#log flag that tells to log ET when using device 18 (manual mode)
@@ -12929,6 +12939,88 @@ def resetTimer(self) -> None:
1292912939
if self.samplingSemaphore.available() < 1:
1293012940
self.samplingSemaphore.release(1)
1293112941

12942+
# prevents the computer from entering standby (idle system sleep) while Artisan is sampling.
12943+
# A system sleep tears down the USB/Bluetooth link to the machine and machines watching the
12944+
# communication (like Kaleido) react on such a connection loss (eg. by starting to cool).
12945+
# NOTE: the display is still allowed to sleep, only the system has to stay awake. A laptop
12946+
# closing its lid still sleeps (clamshell sleep) as this cannot be inhibited by an application.
12947+
def preventSleep(self) -> None:
12948+
if not self.flagKeepAwake:
12949+
return
12950+
try:
12951+
if self.sleep_inhibitor is None:
12952+
from artisanlib.power import SleepInhibitor
12953+
self.sleep_inhibitor = SleepInhibitor()
12954+
self.sleep_inhibitor.inhibit()
12955+
except Exception as e: # pylint: disable=broad-except
12956+
_log.exception(e)
12957+
12958+
# releases the system sleep inhibition acquired by preventSleep()
12959+
def allowSleep(self) -> None:
12960+
try:
12961+
if self.sleep_inhibitor is not None:
12962+
self.sleep_inhibitor.release()
12963+
except Exception as e: # pylint: disable=broad-except
12964+
_log.exception(e)
12965+
12966+
# a system suspension cannot be prevented in all cases (a laptop closing its lid or a user
12967+
# explicitly sending the machine to sleep). As a machine connection established before a
12968+
# suspension is dead afterwards, without the transport necessarily reporting an error or
12969+
# running into a timeout, we watch out for suspensions while sampling to reconnect on wake.
12970+
def startWakeDetection(self) -> None:
12971+
try:
12972+
if self.wake_detector is None:
12973+
from artisanlib.power import WakeDetector
12974+
self.wake_detector = WakeDetector()
12975+
self.wake_detector.start()
12976+
if self.wake_timer is None:
12977+
self.wake_timer = QTimer()
12978+
self.wake_timer.timeout.connect(self.checkWake)
12979+
self.wake_timer.start(_wake_check_interval)
12980+
except Exception as e: # pylint: disable=broad-except
12981+
_log.exception(e)
12982+
12983+
def stopWakeDetection(self) -> None:
12984+
try:
12985+
if self.wake_timer is not None:
12986+
self.wake_timer.stop()
12987+
if self.wake_detector is not None:
12988+
self.wake_detector.stop()
12989+
except Exception as e: # pylint: disable=broad-except
12990+
_log.exception(e)
12991+
12992+
@pyqtSlot()
12993+
def checkWake(self) -> None:
12994+
try:
12995+
if self.wake_detector is None:
12996+
return
12997+
suspension:float = self.wake_detector.check()
12998+
if suspension > 0:
12999+
_log.warning('system resumed after a suspension of %.0fs; reconnecting the machine '
13000+
'(NOTE: the roast timer does not advance while the system is suspended)', suspension)
13001+
self.reconnectMachine()
13002+
self.aw.sendmessage(QApplication.translate('Message','System resumed. Reconnecting...'))
13003+
except Exception as e: # pylint: disable=broad-except
13004+
_log.exception(e)
13005+
13006+
# forces a reconnect of those machine connections which offer this operation and returns the
13007+
# number of connections that were asked to reconnect.
13008+
# NOTE: the classic serial devices re-open their port automatically on the next failing read
13009+
# and the BLE devices are reconnected by their disconnect callbacks, thus both are not
13010+
# handled here
13011+
def reconnectMachine(self) -> int:
13012+
reconnects:int = 0
13013+
machines:list[AsyncComm|KaleidoPort|None] = [self.aw.hottop, self.aw.santoker, self.aw.mugma,
13014+
self.aw.orbiter, self.aw.kaleido]
13015+
for machine in machines:
13016+
if machine is not None:
13017+
try:
13018+
if machine.reconnect():
13019+
reconnects += 1
13020+
except Exception as e: # pylint: disable=broad-except
13021+
_log.exception(e)
13022+
return reconnects
13023+
1293213024
@pyqtSlot()
1293313025
def OnMonitor(self) -> None:
1293413026
try:
@@ -13102,6 +13194,8 @@ def OnMonitor(self) -> None:
1310213194
self.TPalarmtimeindex = None
1310313195

1310413196
self.flagon = True
13197+
self.preventSleep() # keep the computer awake to not lose the connection to the machine
13198+
self.startWakeDetection() # reconnect the machine if the system was suspended nevertheless
1310513199
self.redraw(True,re_smooth_foreground=False, re_smooth_background=True) # there is now foreground at this point; we need to re-smooth background with no curve-smoothing and standard instead of optimal-smoothing on ON
1310613200

1310713201
if self.designerflag:
@@ -13279,6 +13373,8 @@ def OffMonitorCloseDown(self, respectAlwaysON:bool, wasRecording:bool) -> None:
1327913373
except Exception as e: # pylint: disable=broad-except
1328013374
_log.exception(e)
1328113375
QTimer.singleShot(5,self.disconnectProbes)
13376+
self.stopWakeDetection()
13377+
self.allowSleep() # the machine is disconnected, the computer may sleep again
1328213378
# reset the canvas color when it was set by an alarm but never reset
1328313379
if 'canvas_alt' in self.palette:
1328413380
self.palette['canvas'] = self.palette['canvas_alt']

0 commit comments

Comments
 (0)