From ad2088d23158f0ab2182be016778b4c8a342c0dc Mon Sep 17 00:00:00 2001 From: scgbckbone Date: Wed, 24 Jun 2026 11:26:18 +0200 Subject: [PATCH] Fix QR scanner setup and sleep handling --- releases/Next-ChangeLog.md | 5 + shared/scanner.py | 209 ++++++++++++++++++++++++++----------- shared/ux_q1.py | 8 +- 3 files changed, 158 insertions(+), 64 deletions(-) diff --git a/releases/Next-ChangeLog.md b/releases/Next-ChangeLog.md index e788e6e9..87ff4f2d 100644 --- a/releases/Next-ChangeLog.md +++ b/releases/Next-ChangeLog.md @@ -66,3 +66,8 @@ This lists the new changes that have not yet been published in a normal release. - Bugfix: Yikes when showing "QR too big" for a transaction output alone on an output-explorer page - Bugfix: Yikes receiving a malformed full-backup via Key Teleport - Bugfix: Keyboard debounce could leave a key stuck as "pressed" after release when another key was held +- Bugfix: Scanner robustness + - Avoid holding the QR scanner reset line low; reset is now only pulsed and then left deasserted. + - Recover scanner setup failures by retrying configuration and reinitializing on the next scan when needed. + - Prevent delayed scanner sleep commands from racing with a newly started scan. + - Improve scanner shutdown/recovery after scan cancel or command timeout. diff --git a/shared/scanner.py b/shared/scanner.py index 4dd91c79..af048a7a 100644 --- a/shared/scanner.py +++ b/shared/scanner.py @@ -57,17 +57,20 @@ SLOW_BAUD = const(9600) FAST_BAUD = const(57600) RX_BUF_SIZE = const(4350) # big enough for full v40 decoded -# TODO: constructor should leave it in reset for simple lower-power usage; then after -# login we can do full setup (2+ seconds) and then sleep again until needed. - +# TODO: constructor should avoid full setup until after login; after setup, +# command sleep is the known low-power state. class QRScanner: + needs_reinit = False + def __init__(self): self.busy_scanning = False self.scan_light = False # is light on during scanning? self.version = None self.setup_done = False + self.needs_reinit = False + self.sleep_seq = 0 # hodl this lock when communicating w/ QR scanner self.lock = asyncio.Lock() @@ -84,16 +87,21 @@ class QRScanner: # setup hardware, reset scanner and return time to delay until ready from machine import UART, Pin self.serial = UART(2, SLOW_BAUD, rxbuf=RX_BUF_SIZE) - self.reset = Pin('QR_RESET', Pin.OUT_OD, value=0) + self.reset = Pin('QR_RESET', Pin.OUT_OD, value=1) self.trigger = Pin('QR_TRIG', Pin.OUT_OD, value=1) # wasn't needed - # NOTE: reset is active low (open drain) + self.pulse_reset() + + # needs full 2 seconds of recovery time after reset + return 2 + + def pulse_reset(self): + # RESET is active low (open drain). Keep it as a pulse; module docs + # describe low on this pin as wake-up, so don't use it as parking state. self.reset(0) utime.sleep_ms(10) self.reset(1) - - # needs full 2 seconds of recovery time - return 2 + self.needs_reinit = False def set_baud(self, br): # change serial port baud rate @@ -118,56 +126,104 @@ class QRScanner: async def setup_task(self, start_delay): # Task to setup device, and then die. - await asyncio.sleep(start_delay) + async with self.lock: + for attempt in range(3): + await asyncio.sleep(start_delay) - async with self.lock: + try: + await self._configure() + except Exception: + # a step failed or timed out (would have left scanner dead + # until next boot); reset module and start over + await self.blind_shutdown() + if attempt == 2: + break + start_delay = self.reset_stream() + continue - # might need to repeat a few time to get into right state + self.setup_done = True + await self.goto_sleep() + return + self.mark_needs_reinit() + + def reset_stream(self): + self.sleep_seq += 1 + start_delay = self.hardware_setup() + self.stream = asyncio.StreamReader(self.serial, {}) + return start_delay + + def mark_needs_reinit(self): + self.setup_done = False + self.version = None + self.needs_reinit = True + if hasattr(self, 'reset'): + self.reset(1) + + async def blind_shutdown(self): + for baud in (SLOW_BAUD, FAST_BAUD): + self.set_baud(baud) + await self.tx('S_CMD_020D') # return to "Command mode" + await asyncio.sleep_ms(20) + await self.tx('S_CMD_03L0') # turn off bright light + await asyncio.sleep_ms(20) + await self.tx('SRDF0050') # sleep scanner + await asyncio.sleep_ms(150) + await self.tx('SRDF0050') + await asyncio.sleep_ms(20) + + async def _configure(self): + # full config sequence; any step may raise on timeout/framing error + + # might need to repeat a few time to get into right state + for retry in range(5): + baud = await self.probe_baud() + if baud: break + else: + #print("QR Scanner: missing") + raise RuntimeError('no contact') + + try: + await self.txrx('S_CMD_FFFF') # factory reset of settings + except RuntimeError: + await asyncio.sleep_ms(1000) for retry in range(5): baud = await self.probe_baud() if baud: break else: - #print("QR Scanner: missing") - return + raise RuntimeError('no contact after S_CMD_FFFF') - await self.txrx('S_CMD_FFFF') # factory reset of settings + # go to high speed! + if baud != FAST_BAUD: + await self.txrx('S_CMD_H3BR%d' % FAST_BAUD) + self.set_baud(FAST_BAUD) - # go to high speed! - if baud != FAST_BAUD: - await self.txrx('S_CMD_H3BR%d' % FAST_BAUD) - self.set_baud(FAST_BAUD) + # configure it like we want it + await self.txrx('S_CMD_MTRS5000') # 5s to read before fail (unused) + await self.txrx('S_CMD_MT11') # trigger is edge-based (not level) + await self.txrx('S_CMD_MT30') # Same code reading without delay + await self.txrx('S_CMD_MT20') # Enable automatic sleep when idle + await self.txrx('S_CMD_MTRF500') # Idle time: 500ms + await self.txrx('S_CMD_059A') # add CR LF after QR data (important) + await self.txrx('S_CMD_03L0') # light off all the time by default + await self.txrx('S_CMD_0407') # turn on signal for our yellow led - # configure it like we want it - await self.txrx('S_CMD_MTRS5000') # 5s to read before fail (unused) - await self.txrx('S_CMD_MT11') # trigger is edge-based (not level) - await self.txrx('S_CMD_MT30') # Same code reading without delay - await self.txrx('S_CMD_MT20') # Enable automatic sleep when idle - await self.txrx('S_CMD_MTRF500') # Idle time: 500ms - await self.txrx('S_CMD_059A') # add CR LF after QR data (important) - await self.txrx('S_CMD_03L0') # light off all the time by default - await self.txrx('S_CMD_0407') # turn on signal for our yellow led + # settings under continuous scan mode + await self.txrx('S_CMD_MARS0000') # "Modify the duration of single code reading" (ms) + await self.txrx('S_CMD_MARR000') # "Modify the time of the reading interval 0ms" + await self.txrx('S_CMD_MA31') # Enable "Same code reading delay" + await self.txrx('S_CMD_MARI0050') # "Modify the same code reading delay 50ms" - # settings under continuous scan mode - await self.txrx('S_CMD_MARS0000') # "Modify the duration of single code reading" (ms) - await self.txrx('S_CMD_MARR000') # "Modify the time of the reading interval 0ms" - await self.txrx('S_CMD_MA31') # Enable "Same code reading delay" - await self.txrx('S_CMD_MARI0050') # "Modify the same code reading delay 50ms" + # these aren't useful (yet?) and just make things harder to decode. + #await self.txrx('S_CMD_05F1') # add all information on + #await self.txrx('S_CMD_05L1') # output decoding length info on + #await self.txrx('S_CMD_05S1') # STX start char + #await self.txrx('S_CMD_05C1') # CodeID+prefix + #await self.txrx('S_CMD_0501') # prefix on + #await self.txrx('S_CMD_0506') # suffix + #await self.txrx('S_CMD_05D0') # tx total data - # these aren't useful (yet?) and just make things harder to decode. - #await self.txrx('S_CMD_05F1') # add all information on - #await self.txrx('S_CMD_05L1') # output decoding length info on - #await self.txrx('S_CMD_05S1') # STX start char - #await self.txrx('S_CMD_05C1') # CodeID+prefix - #await self.txrx('S_CMD_0501') # prefix on - #await self.txrx('S_CMD_0506') # suffix - #await self.txrx('S_CMD_05D0') # tx total data - - # prevent scanning magic QR to affect settings - await self.txrx('S_CMD_0000') # close setting codes - - self.setup_done = True - - await self.goto_sleep() + # prevent scanning magic QR to affect settings + await self.txrx('S_CMD_0000') # close setting codes async def scan_once(self): # Blocks until something is scanned. Returns it as string @@ -176,6 +232,16 @@ class QRScanner: # - returns a BBQr object at that point self.scan_light = False + if self.needs_reinit: + try: + await self.setup_task(self.reset_stream()) + if self.setup_done: + await asyncio.sleep_ms(200) + except asyncio.CancelledError: + await self.blind_shutdown() + self.mark_needs_reinit() + return None + # wait for reset process to complete (can be an issue right after boot) # - few seconds of boot time needed for retry in range(10): @@ -211,19 +277,22 @@ class QRScanner: finally: # Problem: another valid scan can come in just as we are trying # to get out of scanner mode - for retry in range(10): + for retry in range(3): try: - await self.txrx('S_CMD_020D') # return to "Command mode" - await self.txrx('S_CMD_03L0') # turn off bright light + await self.txrx('S_CMD_020D', timeout=1000) # return to "Command mode" + await self.txrx('S_CMD_03L0', timeout=1000) # turn off bright light #print('rest after %d retries' % retry) break - except: pass - await asyncio.sleep_ms(25) + except Exception: + pass + await asyncio.sleep_ms(50) else: - pass #print('reset failed') + await self.blind_shutdown() + self.mark_needs_reinit() - await self.goto_sleep() + if self.setup_done: + await self.goto_sleep() self.busy_scanning = False # return BBQr object or string if simple QR @@ -254,13 +323,14 @@ class QRScanner: # send specific command until it responds # - it will wake on any command, but not instant # - first one seems to fail 100% + self.sleep_seq += 1 await self.tx('SRDF0051') # blindly at first for retry in range(5): try: await self.txrx('SRDF0051', timeout=50) # 50 ok, 20 too short return - except: + except Exception: # first try usually fails, that's okay... its asleep and groggy pass @@ -270,9 +340,13 @@ class QRScanner: # - need blind retries here # - might be two layers of sleep, and we need this second command after the first # - helps to turn off the yellow LED, and save power as well + self.sleep_seq += 1 + sleep_seq = self.sleep_seq await self.tx('SRDF0050') async def later(): await asyncio.sleep_ms(150) + if sleep_seq != self.sleep_seq or self.busy_scanning: + return await self.tx('SRDF0050') asyncio.create_task(later()) @@ -290,6 +364,22 @@ class QRScanner: #print('tx >> ' + msg) self.serial.write(msg) + async def readexactly_timeout(self, num, timeout, msg=None): + # Avoid asyncio.wait_for_ms here: it can leave the scanner setup task + # stuck after a CancelledError. Convert scanner silence into a normal + # retryable command failure instead. + if timeout is None: + return await self.stream.readexactly(num) + + start = utime.ticks_ms() + while self.stream.s.any() < num: + if utime.ticks_diff(utime.ticks_ms(), start) >= timeout: + #print("no rx after %s" % msg) + raise RuntimeError + await asyncio.sleep_ms(5) + + return await self.stream.readexactly(num) + async def txrx(self, msg, timeout=250): # Send a command, get the corresponding response. # - has a long timeout, collects rx based on framing @@ -310,13 +400,8 @@ class QRScanner: expect = LEN_OKAY rx = b'' while 1: - try: - rx += await asyncio.wait_for_ms(self.stream.readexactly(expect), timeout) - except asyncio.TimeoutError: - if timeout is None: - continue - #print("no rx after %s" % msg) - raise RuntimeError + rx += await self.readexactly_timeout(expect, timeout, msg) + #print('txrx << ' + B2A(rx)) diff --git a/shared/ux_q1.py b/shared/ux_q1.py index 579b941a..2224b934 100644 --- a/shared/ux_q1.py +++ b/shared/ux_q1.py @@ -825,7 +825,6 @@ class QRScannerInteraction: while 1: if task.done(): data = await task - #print("Scanned: %r" % data) break dis.image(None, 40, 'scan_%d' % frames[ph]) @@ -838,7 +837,12 @@ class QRScannerInteraction: data = None break - task.cancel() + if not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass # clear screen right away so user knows we got it dis.clear()