Skip to content

Commit 16a5d0f

Browse files
hint at a stale Bluetooth serial port if the machine does not respond
On macOS a Bluetooth serial port can survive a system suspension as a stale device: /dev/cu.<machine> still exists and opens successfully, but there is no serial connection behind it any longer, so nothing is sent or received. The connect loop then repeats "connection timeout" forever while the user is left with empty readings and no indication of what to do. The port can only be re-established by removing the machine in the system Bluetooth settings and pairing it again (neither toggling Bluetooth nor reconnecting the device rebuilds it, and the Bluetooth daemon cannot be restarted under SIP). - counts the connect attempts of the Kaleido serial transport that opened the port but received no response from the machine and, after three of them, logs a hint and reports it to the user via the new unresponsive_handler - the hint is raised only once per unresponsive phase and re-armed by a successful connect, so a machine that is simply switched off does not repeat the message on every reconnect attempt Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 58be935 commit 16a5d0f

4 files changed

Lines changed: 93 additions & 5 deletions

File tree

src/artisanlib/canvas.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13042,7 +13042,8 @@ def OnMonitor(self) -> None:
1304213042
self.aw.kaleido.start(self.mode, self.aw.kaleidoHost, self.aw.kaleidoPort,
1304313043
serial=kaleido_serial,
1304413044
connected_handler=lambda : self.aw.sendmessageSignal.emit(QApplication.translate('Message', '{} connected').format('Kaleido'),True,None),
13045-
disconnected_handler=lambda : self.aw.sendmessageSignal.emit(QApplication.translate('Message', '{} disconnected').format('Kaleido'),True,None))
13045+
disconnected_handler=lambda : self.aw.sendmessageSignal.emit(QApplication.translate('Message', '{} disconnected').format('Kaleido'),True,None),
13046+
unresponsive_handler=lambda : self.aw.sendmessageSignal.emit(QApplication.translate('Message', '{} does not respond. If connected via Bluetooth, remove the machine in the system Bluetooth settings and pair it again.').format('Kaleido'),True,None))
1304613047
elif self.device == 142:
1304713048
try:
1304813049
from artisanlib.ikawa import IKAWA_BLE

src/artisanlib/kaleido.py

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ class KaleidoPort:
6161

6262
__slots__ = [ '_asyncLoopThread', '_write_queue', '_running', '_default_data_stream', '_ping_timeout', '_open_timeout', '_init_timeout',
6363
'_send_timeout', '_read_timeout', '_ping_retry_delay', '_reconnect_delay', 'send_button_timeout', '_single_await_var_prefix',
64-
'_state', '_pending_requests', '_logging' ]
64+
'_state', '_pending_requests', '_logging', '_unresponsive_connects', '_unresponsive_hint_after' ]
6565

6666
def __init__(self) -> None:
6767
# internals
@@ -78,6 +78,15 @@ def __init__(self) -> None:
7878
self._ping_retry_delay:Final[float] = 1 # in seconds
7979
self._reconnect_delay:Final[float] = 0.5 # in seconds
8080

81+
# counts the connect attempts that opened the port, but never received a response from the
82+
# machine. On macOS a Bluetooth serial port can be left behind by a system suspension: the
83+
# /dev/cu.* device still exists and opens successfully, but there is no serial connection
84+
# behind it any longer and thus data is neither sent nor received. As this is
85+
# indistinguishable from a machine that is turned off, we hint at it only after a couple of
86+
# such attempts (see the unresponsive_handler of serial_connect)
87+
self._unresponsive_connects:int = 0
88+
self._unresponsive_hint_after:Final[int] = 3
89+
8190
self.send_button_timeout:Final[float] = 1.2 # in seconds
8291

8392
# _state holds the last received data of the corresponding var for known all tags
@@ -439,9 +448,21 @@ async def serial_initialize(self, reader: asyncio.StreamReader, writer: asyncio.
439448
except TimeoutError:
440449
_log.debug('SC AR timeout')
441450

451+
# registers a connect attempt that opened the port, but received no response from the machine.
452+
# Returns True exactly once per unresponsive phase, on reaching _unresponsive_hint_after, to
453+
# trigger the hint only once and not on every reconnect attempt
454+
def register_unresponsive_connect(self) -> bool:
455+
self._unresponsive_connects += 1
456+
return self._unresponsive_connects == self._unresponsive_hint_after
457+
458+
# called on a successful connect to re-arm the hint for the next unresponsive phase
459+
def reset_unresponsive_connects(self) -> None:
460+
self._unresponsive_connects = 0
461+
442462
async def serial_connect(self, mode:str, serial:SerialSettings,
443463
connected_handler:Callable[[], None]|None = None,
444-
disconnected_handler:Callable[[], None]|None = None) -> None:
464+
disconnected_handler:Callable[[], None]|None = None,
465+
unresponsive_handler:Callable[[], None]|None = None) -> None:
445466

446467
writer:asyncio.StreamWriter|None = None
447468
while self._running:
@@ -463,6 +484,7 @@ async def serial_connect(self, mode:str, serial:SerialSettings,
463484
await asyncio.wait_for(self.serial_initialize(reader, writer, mode), timeout=self._init_timeout)
464485

465486
_log.debug('connected')
487+
self.reset_unresponsive_connects()
466488
if connected_handler is not None:
467489
try:
468490
connected_handler()
@@ -483,6 +505,16 @@ async def serial_connect(self, mode:str, serial:SerialSettings,
483505
raise exception
484506
except TimeoutError:
485507
_log.debug('connection timeout')
508+
if self.register_unresponsive_connect():
509+
_log.warning('%s opens, but the machine does not respond. If the machine is '
510+
'connected via Bluetooth, the serial port might be stale and has to be '
511+
're-established by removing the machine in the system Bluetooth '
512+
'settings and pairing it again', serial['port'])
513+
if unresponsive_handler is not None:
514+
try:
515+
unresponsive_handler()
516+
except Exception as e: # pylint: disable=broad-except
517+
_log.exception(e)
486518
except Exception as e: # pylint: disable=broad-except
487519
_log.error(e)
488520
finally:
@@ -613,11 +645,13 @@ def markTP(self) -> None:
613645
def start(self, mode:str, host:str = '127.0.0.1', port:int = 80, path:str = 'ws',
614646
serial:SerialSettings|None = None,
615647
connected_handler:Callable[[], None]|None = None,
616-
disconnected_handler:Callable[[], None]|None = None) -> None:
648+
disconnected_handler:Callable[[], None]|None = None,
649+
unresponsive_handler:Callable[[], None]|None = None) -> None:
617650
try:
618651
# initialize data structures
619652
self._state = {}
620653
self._pending_requests = {}
654+
self.reset_unresponsive_connects()
621655

622656
_log.debug('start sampling')
623657
if self._asyncLoopThread is None:
@@ -630,7 +664,7 @@ def start(self, mode:str, host:str = '127.0.0.1', port:int = 80, path:str = 'ws'
630664
connected_handler, disconnected_handler)
631665
else:
632666
coro = self.serial_connect(mode, serial,
633-
connected_handler, disconnected_handler)
667+
connected_handler, disconnected_handler, unresponsive_handler)
634668
asyncio.run_coroutine_threadsafe(coro, self._asyncLoopThread.loop)
635669
except Exception as e: # pylint: disable=broad-except
636670
_log.exception(e)

src/test/unitary/artisanlib/test_kaleido.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -726,3 +726,55 @@ def test_kaleido_methods_from_source_inspection(self) -> None:
726726
# # Test Heater/Fan retrieval (converts int to float)
727727
# result = get_heater_fan_values(75, 50)
728728
# assert result == (75.0, 50.0)
729+
730+
731+
class TestKaleidoUnresponsiveConnect:
732+
"""Test the hint raised when the serial port opens but the machine never responds.
733+
734+
On macOS a Bluetooth serial port survives a system suspension as a stale device: it still
735+
opens successfully while no data passes any longer. The hint is raised after a couple of such
736+
connect attempts and only once per unresponsive phase.
737+
"""
738+
739+
def test_hint_is_raised_after_the_configured_number_of_attempts(self) -> None:
740+
from artisanlib.kaleido import KaleidoPort
741+
742+
kaleido = KaleidoPort()
743+
attempts = kaleido._unresponsive_hint_after
744+
results = [kaleido.register_unresponsive_connect() for _ in range(attempts)]
745+
assert results[:-1] == [False] * (attempts - 1)
746+
assert results[-1] is True
747+
748+
def test_hint_is_raised_only_once(self) -> None:
749+
from artisanlib.kaleido import KaleidoPort
750+
751+
kaleido = KaleidoPort()
752+
for _ in range(kaleido._unresponsive_hint_after):
753+
kaleido.register_unresponsive_connect()
754+
# further failing attempts must not repeat the hint on every reconnect
755+
assert not any(kaleido.register_unresponsive_connect() for _ in range(10))
756+
757+
def test_a_successful_connect_rearms_the_hint(self) -> None:
758+
from artisanlib.kaleido import KaleidoPort
759+
760+
kaleido = KaleidoPort()
761+
attempts = kaleido._unresponsive_hint_after
762+
for _ in range(attempts):
763+
kaleido.register_unresponsive_connect()
764+
kaleido.reset_unresponsive_connects()
765+
results = [kaleido.register_unresponsive_connect() for _ in range(attempts)]
766+
assert results[-1] is True
767+
768+
def test_start_rearms_the_hint(self) -> None:
769+
"""start() resets the counter such that a new session reports an unresponsive machine."""
770+
from unittest.mock import patch
771+
772+
from artisanlib.kaleido import KaleidoPort
773+
774+
kaleido = KaleidoPort()
775+
for _ in range(kaleido._unresponsive_hint_after):
776+
kaleido.register_unresponsive_connect()
777+
with patch('artisanlib.kaleido.AsyncLoopThread'), patch('asyncio.run_coroutine_threadsafe'):
778+
kaleido.start('C', serial=None)
779+
assert kaleido._unresponsive_connects == 0
780+
kaleido.stop()

wiki/ReleaseHistory.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ v4.2.2
2222
- faster app start (startup time redued by ~50%)
2323
- refreshed UI
2424
* FIXES
25+
- reports a hint if the serial port of a Kaleido machine opens, but the machine does not respond, as a Bluetooth serial port can be left behind stale by a system suspension on macOS ([Issue #2226](../../../issues/2226))
2526
- fixes faulty hash generation on files created using `Save As` causing `modified file` warnings on load ([Issue #2205](../../../issues/2205))
2627
- fixes regression causing canvas color not being applied correctly ([Issue #2212](../../../issues/2212))
2728

0 commit comments

Comments
 (0)