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