Source code for Hardware.Microcontroller

  1"""Serial interface for MCU-mediated FPGA measurement.
  2
  3Provides the ``Microcontroller`` class that communicates with a
  4microcontroller over a serial link to program an ICE40 FPGA and
  5retrieve fitness-evaluation data (pulse counts or ADC waveforms).
  6
  7.. note:: Serial protocol details were inferred from code and may need
  8   verification against MCU firmware.
  9"""
 10
 11from dataclasses import dataclass
 12from time import time
 13from serial import Serial
 14from BitstreamEvolutionProtocols import Circuit, DataRequest, FPGA_Compilation_Data, FPGA_Model, Measurement
 15from Logger import Logger
 16from returns.result import Success, Failure # type: ignore
 17
[docs] 18@dataclass 19class MicrocontrollerConfig: 20 """Serial connection parameters for the MCU. 21 22 Attributes 23 ---------- 24 usb_path : str 25 Device path to the serial port (e.g. ``/dev/ttyUSB0``). 26 serial_baud : int 27 Baud rate for the serial connection. 28 read_timeout : float 29 Timeout in seconds for serial read operations. 30 """ 31 32 usb_path: str 33 serial_baud: int 34 read_timeout: float
35
[docs] 36class Microcontroller: 37 """Driver for a serial-connected MCU that programs and reads from an FPGA. 38 39 Opens a persistent serial connection on construction and provides 40 async methods to request waveform or pulse-count measurements. 41 42 Concurrency note: 43 ================= 44 The async methods in this class use blocking pyserial calls and do NOT yield 45 to the event loop. As a result, asyncio.gather() over multiple Microcontroller 46 instances currently runs them sequentially, not in parallel. 47 48 TODO: Migrate serial I/O to serial_asyncio (https://pypi.org/project/serial-asyncio/) 49 so that serial reads genuinely suspend and allow other coroutines to run. This is 50 the server-side component — when the architecture moves to a server-client model, 51 this class (or its replacement) will run on the server and use serial_asyncio. 52 53 Per-Icestick exclusivity: 54 ========================= 55 Each Microcontroller instance manages exactly one Icestick via one serial port. 56 iceprog holds exclusive USB access to the device during compile(). Concurrent 57 calls to request_measurement() on the SAME Microcontroller instance will corrupt 58 each other. The server must serialize calls per-instance (e.g. one 59 asyncio.Semaphore(1) per Microcontroller, or a dedicated worker coroutine per 60 device). Calls on DIFFERENT Microcontroller instances (different Icesticks) can 61 run concurrently. See .claude/docs/hardware_concurrency.md. 62 """ 63
[docs] 64 def __init__(self, fpga: str, logger: Logger, config: MicrocontrollerConfig): 65 """Initialize the serial connection to the MCU. 66 67 Parameters 68 ---------- 69 fpga : str 70 FPGA device identifier passed to ``icepack``/``iceprog``. 71 logger : Logger 72 Logger instance for event and warning messages. 73 config : MicrocontrollerConfig 74 Serial port settings. 75 """ 76 self.__fpga = fpga 77 self.__logger = logger 78 self.__config = config 79 self.__logger.log_event(1, "MCU SETTINGS ================================", config.usb_path, config.serial_baud) 80 self.__serial = Serial( 81 config.usb_path, 82 config.serial_baud, 83 timeout=config.read_timeout 84 ) 85 self.__serial.dtr = False
86
[docs] 87 async def request_measurement(self, measurement: Measurement) -> Measurement: 88 """Compile the circuit and perform the requested measurement. 89 90 Dispatches to ``measure_signal`` or ``measure_pulses`` based on 91 ``measurement.data_request``. The result (or error) is stored in 92 ``measurement.result`` as a ``Success`` or ``Failure``. 93 94 Parameters 95 ---------- 96 measurement : Measurement 97 Measurement descriptor specifying circuit and data type. 98 99 Returns 100 ------- 101 Measurement 102 The same object with ``result`` populated. 103 """ 104 ckt: Circuit = measurement.circuit 105 fpga_data = FPGA_Compilation_Data(FPGA_Model.ICE40, self.__fpga) 106 if measurement.data_request == DataRequest.WAVEFORM: 107 ckt.compile(fpga_data) 108 try: 109 waveform = await self.measure_signal() 110 measurement.result = Success(waveform) 111 except Exception as e: 112 measurement.result = Failure(e) 113 elif measurement.data_request == DataRequest.OSCILLATIONS: 114 ckt.compile(fpga_data) 115 try: 116 data = await self.measure_pulses(measurement.num_samples) 117 measurement.result = Success(data) 118 except Exception as e: 119 measurement.result = Failure(e) 120 # TODO: elif cases for other measurement types... 121 return measurement
122
[docs] 123 def get_available_FPGAs(self) -> list[str]: 124 """Return the list of FPGA device identifiers managed by this MCU.""" 125 return [self.__fpga]
126
[docs] 127 async def measure_signal(self) -> list[int]: 128 """Capture an ADC waveform from the FPGA via the MCU. 129 130 Sends command ``'2'`` to initiate ADC capture, then reads 131 lines between ``START`` and ``FINISHED`` delimiters. 132 133 Returns 134 ------- 135 list[int] 136 Integer ADC samples (~10 us apart). 137 """ 138 buf = [] 139 140 self.__serial.reset_input_buffer() 141 self.__serial.reset_output_buffer() 142 self.__logger.log_event(1, "Reading microcontroller.") 143 # The MCU is expecting a string '2' to initiate the ADC capture from the FPGA (waveform as opposed to pulses) 144 self.__serial.write(b'2') 145 line = self.__serial.read() 146 147 start = time() 148 149 # The MCU returns a START line followed by many lines of data (500 currently) followed by a FINISHED line 150 while b"START\n" not in line: 151 self.__serial.write(b'2') 152 line = self.__serial.read_until() 153 154 if (time() - start) >= self.__config.read_timeout: 155 self.__logger.log_warning(1, "Did not read START from MCU") 156 self.__logger.log_warning(1, "Time Exceeded. Halting MCU Reading.") 157 break 158 159 # TODO This whole section can probably be optimized 160 # Reads in samples from MCU, with each being 10microseconds apart 161 while (b"FINISHED\n" not in line): 162 line = self.__serial.read_until() 163 if line != b"\n" and line != b"START\n" and line != b"FINISHED\n" and line != b"FINISHED\n": 164 buf.append(line) 165 if (time() - start) >= self.__config.read_timeout: 166 self.__logger.log_warning(1, "Time Exceeded. Halting MCU Reading.") 167 break 168 169 self.__logger.log_event(2, "Finished reading microcontroller. Logging data to file.") 170 171 waveform: list[int] = [] 172 for i in buf: 173 if b"FINISHED" not in i: 174 waveform.append(int(i)) 175 176 self.__logger.log_event(2, "Completed reading waveform") 177 return waveform
178
[docs] 179 async def measure_pulses(self, samples: int) -> list[int]: 180 """Collect multiple pulse-count samples from the FPGA. 181 182 Parameters 183 ---------- 184 samples : int 185 Number of times to call ``measure_pulses_once``. 186 187 Returns 188 ------- 189 list[int] 190 Concatenated pulse counts from all sample rounds. 191 """ 192 result = [] 193 for i in range(samples): 194 result.extend(await self.measure_pulses_once()) 195 return result
196
[docs] 197 async def measure_pulses_once(self) -> list[int]: 198 """Perform a single pulse-count read from the MCU. 199 200 Sends command ``'1'`` and polls the serial line for a numeric 201 response, retrying up to 5 times on timeout. Returns ``-1`` 202 on timeout and ``-2`` on parse failure. 203 204 Returns 205 ------- 206 list[int] 207 Parsed pulse count(s) from a single read cycle. 208 """ 209 buf = [] 210 # Poll serial line until START signal 211 self.__logger.log_event(3, f"Starting loop for reading") 212 213 self.__serial.reset_input_buffer() 214 self.__serial.reset_output_buffer() 215 # NOTE The MCU is expecting a string '1' if fitness isn't measured this may be why 216 self.__serial.write(b'1') 217 start = time() 218 self.__logger.log_event(3, f"Starting MCU loop...") 219 220 max_attempts = 5 221 attempts = 0 222 while True: 223 attempts = attempts + 1 224 self.__logger.log_event(3, f"Serial reading...") 225 p = self.__serial.read_until() 226 self.__logger.log_event(3, f"Serial read done") 227 if (time() - start) >= self.__config.read_timeout: 228 self.__logger.log_warning(1, f"Time Exceeded") 229 if attempts >= max_attempts: 230 self.__logger.log_warning(3, f"Exceeded max attempts ({max_attempts}). Halting MCU reading") 231 buf.append(-1) 232 break 233 # TODO We should be able to do whatever this line does better 234 # This is currently doing a poor job at REGEXing the MCU serial return - can be done better 235 # It's supposed to handle exceptions from transmission loss (i.e. dropped or additional spaces, shifted colons, etc) 236 self.__logger.log_event(3, "Pulled", p, f"from MCU") 237 if (p != b"" and b":" not in p and b"START" not in p and b"FINISH" not in p and b" " not in p): 238 p = p.translate(None, b"\r\n") 239 buf.append(p) 240 break 241 242 end = time() - start 243 244 self.__logger.log_event(2, 'Length of buffer:', len(buf)) 245 if len(buf) == 0: 246 buf.append(-1000) # This should never happen 247 248 result = [] 249 for i in range(len(buf)): 250 self.__logger.log_event(2, f'Buffer entry {i}:', buf[i]) 251 try: 252 result.append(int(buf[i])) 253 except ValueError: 254 result.append(-2) 255 256 return result
257