Skip to content

Commit 77dee4b

Browse files
author
Amperstrand
committed
Add hardware-in-the-loop testing support
Enables running the existing integration tests on real STM32F469 hardware instead of only the simulator. The test controller mirrors the SimController interface (start/load/query/shutdown) over serial UART for GUI commands and USB VCP for the Bitcoin protocol. Firmware changes are gated behind a build-time flag (HIL_ENABLED) set via a generated build_config.py. Default is off — zero impact on normal debug or production builds. When enabled: - HIL command handler listens on ST-Link debug UART (9600 baud) for TEST_STATUS, TEST_SCREEN, TEST_UI, TEST_WIPE, TEST_FINGERPRINT, TEST_MNEMONIC, and TEST_KEYSTORE commands - USBHost is auto-enabled before unlock so VCP is ready for tests - Debug logging goes to ST-Link UART via debug_trace.py (bypasses dupterm which is disabled when USB is active) - Network defaults to regtest for testing Build: make hardwareintheloop (or make hardwareintheloop-test to also run tests). This is a thin wrapper around make debug with HIL=1. Also fixes a bug in test/integration/util/rpc.py where batch RPC responses without an "error" key crash with KeyError — this prevents the RPC test module from loading on any Bitcoin Core version. New files (test infrastructure only): src/hil.py, src/debug_trace.py — firmware-side HIL support test/hil/controller.py — HardwareController for serial UART/USB VCP test/integration/hardwareintheloop.py — test runner Modified files (all guarded by hil_test_mode flag unless noted): src/platform.py — flag + usb_connected() bypass src/main.py — dupterm disable, regtest force src/hosts/usb.py — USB auto-enable, exception logging src/specter.py — HIL handler, listener, keystore wiring Makefile — build_config.py generation, HIL targets test/integration/util/rpc.py — KeyError fix (not HIL-specific) Test results (STM32F469 hardware + Bitcoin Core v29.3): 15/16 pass — test_miniscript fails due to BDB deprecation in v29
1 parent df07bb6 commit 77dee4b

12 files changed

Lines changed: 1023 additions & 3 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ release
1010
.DS_Store
1111
.direnv
1212
src/git_info.py
13+
src/build_config.py

Makefile

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,8 @@ disco: $(TARGET_DIR) mpy-cross $(MPY_DIR)/ports/stm32 git-info
5252
cp $(MPY_DIR)/ports/stm32/build-STM32F469DISC/firmware.hex \
5353
$(TARGET_DIR)/specter-diy.hex
5454

55-
# disco board with bitcoin library
56-
debug: $(TARGET_DIR) mpy-cross $(MPY_DIR)/ports/stm32 git-info
55+
# disco board - debug build (includes build_config.py for HIL support)
56+
debug: $(TARGET_DIR) mpy-cross $(MPY_DIR)/ports/stm32 git-info src/build_config.py
5757
@echo Building firmware
5858
make -C $(MPY_DIR)/ports/stm32 \
5959
BOARD=$(BOARD) \
@@ -63,6 +63,7 @@ debug: $(TARGET_DIR) mpy-cross $(MPY_DIR)/ports/stm32 git-info
6363
FROZEN_MANIFEST=$(FROZEN_MANIFEST_DEBUG) \
6464
DEBUG=$(DEBUG) \
6565
CFLAGS_EXTRA="$(MPY_CFLAGS)" && \
66+
rm -f src/build_config.py && \
6667
arm-none-eabi-objcopy -O binary \
6768
$(MPY_DIR)/ports/stm32/build-STM32F469DISC/firmware.elf \
6869
$(TARGET_DIR)/debug.bin && \
@@ -99,3 +100,16 @@ clean:
99100
FROZEN_MANIFEST=$(FROZEN_MANIFEST_DISCO) clean
100101

101102
.PHONY: all clean git-info
103+
104+
# Build config (auto-generated at build time, frozen into firmware).
105+
# Set HIL=1 to enable hardware-in-the-loop test mode.
106+
src/build_config.py:
107+
@printf "HIL_ENABLED = %s\n" "$(or $(HIL),0)" > $@
108+
109+
hardwareintheloop:
110+
$(MAKE) debug HIL=1
111+
112+
hardwareintheloop-test:
113+
cd test/integration && python3 hardwareintheloop.py
114+
115+
.PHONY: hardwareintheloop hardwareintheloop-test

src/debug_trace.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import sys
2+
from io import BytesIO
3+
4+
5+
def _write_bytes(data):
6+
"""Write debug output to ST-Link UART only.
7+
8+
USB VCP is reserved for Bitcoin protocol communication.
9+
ST-Link UART (/dev/ttyACM0) is for debug logs and HIL testing.
10+
"""
11+
wrote = False
12+
try:
13+
import platform
14+
uart = getattr(platform, "stlk", None)
15+
if uart is not None:
16+
uart.write(data)
17+
wrote = True
18+
except Exception:
19+
pass
20+
if not wrote:
21+
try:
22+
print(data.decode().rstrip("\n"))
23+
except Exception:
24+
pass
25+
26+
27+
def log(tag, message):
28+
_write_bytes(("[%s] %s\n" % (tag, message)).encode())
29+
30+
31+
def log_exception(tag, exc):
32+
log(tag, "EXCEPTION: %s" % exc)
33+
b = BytesIO()
34+
sys.print_exception(exc, b)
35+
_write_bytes(("[%s] TRACEBACK START\n" % tag).encode())
36+
_write_bytes(b.getvalue())
37+
if not b.getvalue().endswith(b"\n"):
38+
_write_bytes(b"\n")
39+
_write_bytes(("[%s] TRACEBACK END\n" % tag).encode())

src/hil.py

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
"""
2+
Hardware-in-the-Loop (HIL) test mode module.
3+
4+
Enables automated testing over the debug UART (ST-Link VCP).
5+
Activated by setting HIL_ENABLED = True in build_config.py (generated at build time).
6+
7+
Commands (sent over UART, newline-terminated):
8+
TEST_STATUS -> OK:READY
9+
TEST_SCREEN -> OK:SCREEN:<ClassName>:<id>[:<title>]
10+
TEST_KEYSTORE -> OK:KEYSTORE:<name>
11+
TEST_UI:<json> -> OK:UI (pass JSON value to screen.set_value)
12+
TEST_WIPE -> OK:WIPED (wipe wallet storage, then reset)
13+
TEST_RESET -> OK:RESET (soft reset)
14+
TEST_FINGERPRINT -> OK:FINGERPRINT:<hex>
15+
TEST_MNEMONIC -> OK:MNEMONIC:<words>
16+
17+
Examples:
18+
TEST_UI:"" -> set_value("") - proceed with default
19+
TEST_UI:1 -> set_value(1) - select option 1
20+
TEST_UI:true -> set_value(True) - confirm
21+
TEST_UI:false -> set_value(False) - cancel
22+
TEST_UI:"abandon ..." -> set_value("abandon ...") - mnemonic
23+
"""
24+
25+
HIL_DEFAULT_PIN = "1234"
26+
27+
_active_keystore_name = "unknown"
28+
_active_keystore_ref = None
29+
30+
31+
def set_keystore_name(name):
32+
global _active_keystore_name
33+
_active_keystore_name = name
34+
35+
36+
def set_keystore_ref(ks):
37+
global _active_keystore_ref
38+
_active_keystore_ref = ks
39+
40+
41+
def _get_keystore():
42+
return _active_keystore_ref
43+
44+
45+
import json
46+
from debug_trace import log, log_exception
47+
48+
49+
class HILCommandHandler:
50+
"""Handles HIL test commands received over UART.
51+
52+
Mirrors the behavior of TCPGUI.tcp_loop() from the simulator.
53+
"""
54+
55+
def __init__(self, uart, gui=None):
56+
self.uart = uart
57+
self.gui = gui
58+
self._buffer = b""
59+
60+
def set_gui(self, gui):
61+
"""Set GUI reference after initialization."""
62+
self.gui = gui
63+
64+
def poll(self):
65+
"""Poll UART for incoming commands and process them.
66+
67+
Called periodically by _hil_listener task.
68+
Returns True if a command was processed, False otherwise.
69+
"""
70+
if self.uart is None:
71+
return False
72+
73+
# Read available data
74+
try:
75+
chunk = self.uart.read(64)
76+
except Exception as e:
77+
log("HIL", "read error: %s" % e)
78+
return False
79+
80+
if chunk is None or len(chunk) == 0:
81+
return False
82+
83+
log("HIL", "RECV: %d bytes" % len(chunk))
84+
85+
# Accumulate in buffer
86+
self._buffer += chunk
87+
88+
# Process complete lines (newline-terminated)
89+
processed = 0
90+
while b"\n" in self._buffer:
91+
try:
92+
line, self._buffer = self._buffer.split(b"\n", 1)
93+
except ValueError:
94+
break
95+
96+
line = line.strip()
97+
if len(line) == 0:
98+
continue
99+
100+
self._process_line(line.decode())
101+
processed += 1
102+
103+
if processed > 0:
104+
log("HIL", "Processed %d commands" % processed)
105+
106+
return True
107+
108+
def _process_line(self, line):
109+
"""Process a single command line."""
110+
log("HIL", "CMD: %s" % line[:50])
111+
112+
# TEST_STATUS - device ready check
113+
if line == "TEST_STATUS":
114+
self._respond("OK:READY")
115+
return
116+
117+
if line == "TEST_SCREEN":
118+
self._respond(self._screen_info())
119+
return
120+
121+
if line == "TEST_KEYSTORE":
122+
self._respond("OK:KEYSTORE:%s" % _active_keystore_name)
123+
return
124+
125+
# TEST_UI:<json> - inject value into current screen
126+
if line.startswith("TEST_UI:"):
127+
json_val = line[len("TEST_UI:"):]
128+
self._inject_value(json_val)
129+
return
130+
131+
# TEST_RESET - soft reset
132+
if line == "TEST_RESET":
133+
self._respond("OK:RESET")
134+
import pyb
135+
pyb.hard_reset()
136+
return
137+
138+
# TEST_WIPE - wipe wallet and keystore storage
139+
if line == "TEST_WIPE":
140+
self._wipe_storage()
141+
return
142+
143+
# TEST_FINGERPRINT - get current keystore fingerprint
144+
if line == "TEST_FINGERPRINT":
145+
self._get_fingerprint()
146+
return
147+
148+
# TEST_MNEMONIC - export currently loaded mnemonic
149+
if line == "TEST_MNEMONIC":
150+
self._get_mnemonic()
151+
return
152+
153+
# Fallback: try to parse as JSON (mirrors TCPGUI behavior)
154+
try:
155+
json.loads("[%s]" % line)
156+
self._inject_value(line)
157+
return
158+
except Exception:
159+
pass
160+
161+
log("HIL", "Unknown command: %s" % line)
162+
self._respond("ERR:UNKNOWN")
163+
164+
def _respond(self, message):
165+
"""Send response over UART."""
166+
if self.uart is not None:
167+
self.uart.write(("%s\r\n" % message).encode())
168+
log("HIL", "RSP: %s" % message)
169+
170+
def _inject_value(self, json_val):
171+
"""Parse JSON value and inject into current screen.
172+
173+
Mirrors TCPGUI.tcp_loop() behavior:
174+
- val = json.loads("[%s]" % cmd)[0]
175+
- if self.scr is not None: self.scr.set_value(val)
176+
"""
177+
if self.gui is None:
178+
log("HIL", "No GUI for value injection")
179+
self._respond("ERR:NO_GUI")
180+
return
181+
182+
# Parse JSON value (wrapped in array to handle all types)
183+
try:
184+
val = json.loads("[%s]" % json_val)[0]
185+
except Exception as e:
186+
log("HIL", "JSON parse error: %s" % e)
187+
self._respond("ERR:JSON")
188+
return
189+
190+
# Inject into current screen (same pattern as TCPGUI)
191+
try:
192+
scr = self.gui.scr
193+
if scr is not None:
194+
if type(scr).__name__ == "PinScreen" and hasattr(scr, "pin"):
195+
pin_val = val
196+
if pin_val == "":
197+
pin_val = HIL_DEFAULT_PIN
198+
if pin_val is None:
199+
pin_val = ""
200+
scr.pin.set_text(str(pin_val))
201+
scr.release()
202+
else:
203+
scr.set_value(val)
204+
log("HIL", "Injected: %s" % repr(val)[:50])
205+
self._respond("OK:UI")
206+
else:
207+
log("HIL", "No screen available")
208+
self._respond("ERR:NO_SCREEN")
209+
except Exception as e:
210+
log_exception("HIL", e)
211+
self._respond("ERR:INJECT")
212+
213+
def _screen_info(self):
214+
if self.gui is None:
215+
return "ERR:NO_GUI"
216+
try:
217+
scr = self.gui.scr
218+
if scr is None:
219+
return "OK:SCREEN:None:0"
220+
title = ""
221+
try:
222+
if hasattr(scr, 'title'):
223+
t = scr.title
224+
if hasattr(t, 'get_text'):
225+
title = t.get_text()
226+
elif isinstance(t, str):
227+
title = t
228+
except Exception:
229+
pass
230+
if title:
231+
return "OK:SCREEN:%s:%d:%s" % (type(scr).__name__, id(scr), title)
232+
return "OK:SCREEN:%s:%d" % (type(scr).__name__, id(scr))
233+
except Exception:
234+
return "ERR:NO_SCREEN"
235+
236+
def _wipe_storage(self):
237+
import platform
238+
try:
239+
wallet_path = platform.fpath("/qspi/wallets")
240+
platform.delete_recursively(wallet_path)
241+
log("HIL", "Wiped: %s" % wallet_path)
242+
keystore_path = platform.fpath("/flash/keystore")
243+
if keystore_path:
244+
platform.delete_recursively(keystore_path)
245+
log("HIL", "Wiped: %s" % keystore_path)
246+
self._respond("OK:WIPED")
247+
except Exception as e:
248+
log_exception("HIL", e)
249+
self._respond("ERR:WIPE_FAIL")
250+
251+
def _get_fingerprint(self):
252+
try:
253+
ks = _get_keystore()
254+
if ks is None:
255+
self._respond("ERR:NO_KEYSTORE")
256+
return
257+
fp = ks.fingerprint
258+
if fp is None:
259+
self._respond("ERR:NO_FINGERPRINT")
260+
else:
261+
from binascii import hexlify
262+
self._respond("OK:FINGERPRINT:%s" % hexlify(fp).decode())
263+
except Exception as e:
264+
log_exception("HIL", e)
265+
self._respond("ERR:FINGERPRINT_FAIL")
266+
267+
def _get_mnemonic(self):
268+
try:
269+
ks = _get_keystore()
270+
if ks is None:
271+
self._respond("ERR:NO_KEYSTORE")
272+
return
273+
mn = ks.mnemonic
274+
if mn is None:
275+
self._respond("ERR:NO_MNEMONIC")
276+
else:
277+
self._respond("OK:MNEMONIC:%s" % mn)
278+
except Exception as e:
279+
log_exception("HIL", e)
280+
self._respond("ERR:MNEMONIC_FAIL")

src/hosts/usb.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,14 @@ def init(self):
3030
self.usb.init(flow=(pyb.USB_VCP.RTS | pyb.USB_VCP.CTS))
3131
if platform.simulator:
3232
print("Connect to 127.0.0.1:8789 to do USB communication")
33+
if platform.hil_test_mode:
34+
self.settings["enabled"] = True
35+
platform.enable_usb()
3336

3437
def load_settings(self, *args, **kwargs):
3538
super().load_settings(*args, **kwargs)
39+
if platform.hil_test_mode:
40+
self.settings["enabled"] = True
3641
if self.is_enabled:
3742
platform.enable_usb()
3843
else:
@@ -171,6 +176,9 @@ async def update(self):
171176
sys.print_exception(e)
172177
# for all other exceptions - send back generic message
173178
except Exception as e:
179+
if platform.hil_test_mode:
180+
from debug_trace import log_exception
181+
log_exception("USB", e)
174182
if platform.simulator:
175183
self.respond(b"error: Unknown error %s" % e)
176184
sys.print_exception(e)

0 commit comments

Comments
 (0)