|
| 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") |
0 commit comments