Fix QR scanner setup and sleep handling
What changed, and why it matters
This commit fixes several bugs in how the COLDCARD Q1 hardware wallet talks to its built-in QR-code scanner. The changes make scanner startup more reliable, prevent stale 'go to sleep' commands from accidentally stopping a scan that just started, and recover better when commands time out or the user cancels. The changelog calls these 'Scanner robustness' fixes. There is no direct evidence this is a security vulnerability that can be exploited by an attacker; it is primarily a reliability/stability improvement. However, because the QR scanner is used to read sensitive data such as Bitcoin transactions and seed backups, any crash or misbehavior during scanning could in theory affect user funds or cause denial of service, so the change has defensive-security value.
Treat as a reliability fix rather than an urgent security patch. Include in normal firmware release testing. Users who rely on the QR scanner for critical operations should update when a release containing this commit is available. No immediate incident response is indicated by the diff alone.
Security signals we found
Hardware peripheral reset line held in asserted state (potential wake/power issue)
Async sleep command could race with newly started scan (state-management bug)
CancelledError from asyncio.wait_for_ms could strand setup task
Scanner setup failures previously left module dead until reboot
ChangeLog labels changes as 'Scanner robustness' bugfixes
Evidence from the diff
The patch refactors shared/scanner.py and shared/ux_q1.py. Key technical changes: (1) QR_RESET is now pulsed low then driven high, instead of being held low, because the module treats a persistent low as a wake-up signal. (2) setup_task() now retries configuration up to three times and calls a new blind_shutdown() and reset_stream() on failure, marking the scanner for reinit if all attempts fail. (3) scan_once() checks a new needs_reinit flag and re-runs setup before scanning. (4) goto_sleep() uses a sleep_seq counter so that a delayed second ‘SRDF0050’ sleep command is dropped if a new scan has started in the meantime, preventing a race. (5) txrx() no longer uses asyncio.wait_for_ms, which could leave the setup task stuck after CancelledError; instead a custom readexactly_timeout() raises RuntimeError on silence. (6) QRScannerInteraction cancels and awaits the scan task properly. These are robustness fixes, not a patch for a demonstrated exploit.
Changed components
shared/scanner.pyshared/ux_q1.pyCOLDCARD Q1 QR scanner subsystemInspect captured patch +163 / −69
diff --git a/releases/Next-ChangeLog.md b/releases/Next-ChangeLog.md
index e788e6e..87ff4f2 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 4dd91c7..af048a7 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)
+
+ 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
- async with self.lock:
+ 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')
- # might need to repeat a few time to get into right state
+ 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
-
- 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)
-
- # 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"
-
- # 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()
+ raise RuntimeError('no contact after S_CMD_FFFF')
+
+ # 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
+
+ # 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
+
+ # 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 579b941..2224b93 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()
Why this scored 32/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.