Mouses. They exist. They function. However, they have largely remained the same for years. We have lightweight mice, ergonomic mice, gaming mice, office mice, and the list goes on. These largely function the same. Trackpads get a bit unique in that they offer users the ability to do more with gestures. This gives users powerful methods that can boost their productivity and workflows. Unfortunately, mice do not offer a natural way to achieve what trackpads do. Trackpads are confined to a fixed area. So, I aim to explore if we can use machine learning, motion sensors, and more to create an unconstrained, free-floating, gesture-based mouse.
I want to note that, unlike some of my other projects, this turned out to be a POC dev board (V1), and I am working on a V2 to take this project a step further. Unfortunately, Trump's tariffs forced me to make some quick decisions and quick design choices to be able to explore this concept before PCBs got a SUBSTANTIAL increase in price from my typical manufacturer. With that in mind, let's dive in!
To keep the spirit of this project being a 'dev board,' my main goal was to include as many sensors as I thought I would want to use and experiment with. In addition to that, I wanted to keep my communication protocol options open so as V2 comes into fruition, I can continue to experiment with the software. It was important this board remained extensible for testing since it is still not clear how expensive PCBs will get in the upcoming weeks/months. With that in mind, let's discuss some key features of this project:
Machine Learning on Device: The board is designed to run lightweight models directly on the microcontroller, so I can experiment with gesture recognition, motion classification, and other real-time use cases.
Data Processing & Handling: A big part of this project is figuring out how to handle all the data coming in. That meant structuring sensor streams, handling pre/post-processing, and making sure I can store or transmit it efficiently. Whether for logging for training or running inference live, the board is set up to handle both.
Sensor Playground: This was really the heart of the dev board. I loaded the board with as many sensors (and redundancies) as I thought I would need: IMU, magnetometer, Hall sensors, microphone, etc. Having everything on one platform lets me rapidly prototype ideas without waiting for another board spin. More importantly, it lets me experiment while I finalize what works and what doesn't before a V1 spin-up (as the cost is a huge factor at the moment).
The heart of it all. This MCU was a perfect fit for a dev board. It is bulky, but it offered:
1) Huge variety of peripherals for my sensors.
2) Large flash and SRAM made it extensible for future tests and very useful for on-device machine learning applications.
3) This is part of ST's L line. Ultra-low power applications like deep-sleep will be very useful when I start using battery power instead of USB-C power.
This board was one of those 'quick' decisions that came from the suddenness of tariffs. I knew that routing the Bluetooth antenna, power management, etc., would take both time and money. So I found a 'redundancy' board that:
1) Can handle USB-C data transfer and stepping down USB-C power.
2) Has comm. protocols like Bluetooth.
3) Has redundant equipment like IMU, microphone, and MCU that can handle light ML models.
This is a simple 3V3 to 1V8 LDO. An LDO felt like the right choice here since:
1) Voltage differential! 3V3 to 1V8 is fairly small with approx. 53% efficiency. With about a max current of 100mA expected power dissipation should only be about (3.3 - 1.8) * 100mA, or .20 W.
2) Simplicity! Yes, a switching regulator will be more efficient but needing just an input and output cap is very useful when needing a small footprint.
The ICM-20948 is a nice little IMU that I have (retrospectively) really enjoyed working with. It is:
1) Lower power and really small!
2) 9-axis! A lot of data for us to gather!
3) I2C/SPI support. We have a lot of peripherals, but the flexibility will be useful as we move onto V2 (for space, primarily).
This is a PDM microphone that I thought would be a great addition for a potential ML sensor. It would open the doors for sound classification, which could prove to be useful for a motion mouse.
Our MCU, which comes with an SAI peripheral, should have no problem interfacing with the mic in a simple, easy way.
The MLX is a Hall-effect sensor that adds some 'personality' to the sensor suite. The ICM-20948 already comes with a 3-axis magnetometer; however, this Hall Effect sensor has potential for opportunities such as 3-axis location tracking(with the inclusion of magnets). This was a 'fun' sensor I hope to be able to experiment some more with this dev board.
Since this was a rush order dev board, the schematic came together quickly. The XIAO handled redundancy and power, while the STM32 and sensors were meant to be the 'main' powerhouse. The core principle behind this dev board was redundancy and testability. The worry was less about the hardware and more about whether it was even possible to implement the classification needed to make this motion mouse work. That means there was a bigger need for the firmware and core logic to work more than creating a 'solid' hardware design.
This post focuses on the new and interesting design aspects of this project. Standard components—like the ST-Link programming interface, LDO selection, and ST’s MCU best hardware design practices—are reused from earlier designs and won’t be covered here.
I could write a paragraph on how this works, but since I have already covered the components shown in the schematic, I instead will leave you all with a wonderful, easy-to-follow block diagram instead.
If you're paying attention, you'll notice our XIAO and STM32 are running on different voltages, yet communication is happening through them. Yes, this is, in fact, not possible. Sadly I omitted a level shifter in this design. But fear not! We Frankenstein this into our development board later on. Otherwise, the schematic is solid, mostly because we offloaded a lot of the 'hard stuff' to thAe XIAO (don't worry, will dive deep into that).
This board was much easier to route than my previous project, the capacitive keyboard, so there is not too much to say about it. I used many header pins to expose additional connections *just in case*. I kept decoupling and bypass capacitors close to their relevant ICs. I constrained myself to a *smallish* board that I can still turn into a ring form factor. I made my power traces larger. Lastly, I kept this to a basic two-layer board where the majority of my power/signal traces were on TOP and a GND plane occupied BOTTOM.
Initially I wanted to really constrain myself to an even smaller footprint; however, there was no need, as this board is meant to be a playground more than the fully functional idea.
Success!
It’s always a great feeling when the power LED turns on and you can flash your MCU on the first try. But you might be wondering… what’s that Frankenstein looking board hanging off the bottom? Well, not everything works the first time around. In this case, once power was established, I ran a quick communication check. UART is very simple to set up on both the ST and the XIAO, so it should have been straightforward. Surprisingly, I couldn’t get UART working between the two.
That meant debugging time. Step one was making sure everything was wired correctly: ST TX to XIAO RX and vice versa. I traced the connections and cross-referenced them to the schematic. Next, I pulled out the logic analyzer to see if either side wasn’t programmed properly or not transmitting. Probing the STM32 TX, I saw packets. Probing the XIAO TX, I also saw packets. That’s when it hit me: I was missing a crucial piece of hardware. A level shifter! All my hardware was running at 1.8 V, while the XIAO was running at 3.3 V. The two ICs simply couldn’t talk at those levels.
Without communication, I couldn’t get data from the ST to my computer since I was relying on the XIAO’s USB-C connection. And since PCB re-spins are expensive, ordering new boards wasn’t an option. So, I ordered a level shifter instead. Wiring it up was straightforward; you just provide a 1.8 V reference, a 3.3 V reference, and connect your UART signals. Soldering it, however, wasn’t as easy.
The XIAO pins were through-hole, so those were simple. The ST pins, not so much. I couldn’t run a wire from the top without blocking space for a future case, and soldering directly to IC pins was out of the question. Looking back at my schematic, I realized I had routed my UART signals on the bottom layer. Perfect. I used the vias between top and bottom layers to drop in wires and solder to. Not the cleanest or most reliable connection, but good enough to get me out of this pickle. Before moving onto the firmware, a nice PCB needs a case, so I had to do a little arts and crafts before programming.
Before describing the code, I want to take some time to describe what the goal of this development board is. I did not want to gather random sensor data and call this a win; the point of this board was to explore if a smaller, more focused V2 was worth it (and to continue to experiment with sensors later on). So I had a few software goals:
Gather IMU data from the XIAO and STM32.
Communicate between the XIAO and STM32 to form an IMU packet.
Communicate from the XIAO to the PC and establish a training/data-gathering pipeline.
Get actionable results from the IMU data by running an on-device ML model.
Receive those actionable results back to the PC.
Following the high-level block diagram, we have to do some initialization in our microcontroller before we can do anything. We do the following:
Initialize the hardware peripherals (CLOCK, GPIO, SPI, UART); this is set up by the hardware abstraction layer.
Initially the system clock was running at 16 MHz, which was too slow to classify the data and send it. So this was increased to 48 MHz.
Increasing the system clock made the SPI baud rate 24 Mbits/s, which exceeded ICM limits, so I used a pre-scaler for 16 (3 Mbits/s) to receive data again.
Initialize the IMU according to the datasheet.
Initialize the classifier (this is provided by our TFLite framework, Edge Impulse).
Arm/prime our UART (so we can be ready to receive interrupt-driven data from XIAO right away).
Transmit our RDY signal (this takes the XIAO out of a waiting loop)
This handshaking was necessary, as I would get out-of-sync packets when rebooting/initializing/programming the STM32.
Set our state machine to 'WAITING FOR COMMAND.'
In our first state, we wait for the XIAO to send its IMU data (after it is freed by the STM32 'RDY').
We handle the 'uart_packet_ready' in our UART's callback. We use a ping-pong buffer approach so that we can read and use a data packet while we receive the next. On each completed packet, we check if it is 'okay.' This means check for our sync 1 & 2 packets, packet ID, and packet length. If we meet this requirement, we have a 'good' packet and can move onto the 'IMU REQUESTED' state.
The IMU requested state tells our STM32 that it is okay to decode the XIAO packet.
The decode code is fairly straightforward. We drop our sync, ID, and length header to grab only the IMU data. From there we can move to the first step in our ML pipeline, which is reading our PCB's IMU.
The IMU returns 12 bytes of data, LSB. So, we shift the MSB first and OR to get the proper accel and gyro data (we use just the raw values).
Now that we have the ST's IMU data, we can combine the two packets. In production, we should be doing inference on the combined packet. However, before any of that happens, we, of course, need to gather data and train our model. So, we send the data packet back to the XIAO with a 'trash' inference packet (essentially saying inference didn't happen), and we write the XIAO IMU + STM32 IMU packet directly to the PC over the XIAO's USB-C. This leads us to our logging methodology and training. (We'll cover the step_update_window and step_infer_or_reuse functions later).
Now that we have a solid communication method and data packets between our microcontrollers, we can start to build our data pipeline and model. This is a crucial next step, as, if you're familiar with ML at all, a solid dataset is vital for good results. A well-executed data pipeline makes gathering the large amount of data you will inevitably need way easier, saving you time in the future. This would be increasingly important because I anticipated that with each new iteration, we will have to regather data and train the model with the new form factor and IMU orientations, for example.
Hardware Class
AutoLogger Class
The data collection pipeline consists of two main classes:
1) Hardware Class:
This class handles low-level data unpacking from the Arduino and STM32 IMU packets. It also includes a method to compute and return the larger gyro magnitude between the two IMUs. This magnitude will act as a motion indicator; when a user exceeds a threshold, the system knows that movement has begun and data logging should start.
2) AutoLogger Class:
This class manages the automated data collection process and user interaction. It works as follows:
(a) When the AutoLogger starts, it opens a terminal interface that waits for user input. The user can select one of five labels using the keys u/d/l/r/i (up, down, left, right, idle).
(b) The logger continuously reads IMU data from the serial port into a buffer, checking for complete packets based on the header and frame length.
(c) Initially, this setup worked but produced inconsistent results; motion starts and stops weren’t clean, and clip durations varied too much for reliable ML training. To fix this, the system used the gyro magnitude from the Hardware class. Each unpacked frame’s magnitude is stored in a magnitude buffer.
(d) When the user presses ‘c’, the system performs a calibration by computing the median and dispersion (MAD) of recent magnitudes. Once a label is armed, motion above this threshold automatically triggers recording. This ensured repeatable clip detection without needing to press a key for every gesture.
(e) To capture full motion's context, a rolling pre-buffer is also used. When motion starts, a short pre-roll of previous samples is included in the clip so that the onset isn’t missed and timing remains consistent.
(f) Finally, all samples are saved to a CSV file containing timestamps, IMU readings, and the assigned label. This was perfect for feeding into the Edge Impulse framework for training.
As always, the code can be found here:
I know, I know. Technically this is not programming; however, it is a crucial step in getting working inference. I am not, by any means, an expert in machine learning, so I won't be going in-depth about specific architectures; honestly a lot of this was trial and error on my part, but I did come out with a few key takeaways before V2.
Data Acquisition:
Thanks to the automated data pipeline I built earlier, data collection was fairly straightforward. Even so, I made sure to follow a few key guidelines to maintain quality and consistency:
Balanced dataset: I gathered roughly the same amount of data for each label (up, down, left, right, idle) to avoid bias in training.
Consistent clip duration: Each clip was around 2.5 seconds (≈2500 ms); long enough to capture a full gesture while keeping latency manageable.
80/20 split: I maintained an 80% training / 20% testing ratio to properly validate performance.
Visual sanity checks: Because the dataset wasn’t massive, I manually verified that each recording looked correct. For example, “idle” accelerometer readings should appear flat, while an “up” gesture should show distinct gyro movement followed by stabilization.
These small checks went a long way in keeping my data usable and reducing noise before it ever touched a model.
Impulse Design (Edge Impulse Studio)
Once the data was uploaded to Edge Impulse, I configured the impulse, the sequence of signal-processing and learning blocks that define how data becomes predictions.
Window size and stride:
With a sampling rate of about 10 Hz, I used a slightly overlapping window stride and a window size large enough to encompass an entire gesture. This ensures the model sees the full motion context rather than partial movement.
Spectral features:
Instead of feeding raw time-domain data directly, I applied spectral analysis (FFT-based). This converts the sensor readings into frequency-space representations—highlighting the dominant frequencies of each gesture. For example, a quick “up” flick might have higher-frequency components than a slow “down.”
Classifier choice:
I started with a simple dense neural network classifier, which performs well on moderately sized datasets. Combined with spectral features, this setup effectively learns patterns in the frequency domain without overfitting to noise.
Output labels:
The output features directly corresponded to my five motion classes (up, down, left, right, idle). I left out "down" gestures at first to focus on a clean, separable dataset.
Features:
After generating features, Edge Impulse provides a feature explorer. This is a visualization of how your samples cluster in high-dimensional space. Ideally, each label forms a distinct cluster, meaning the model can easily separate them.
With only about 8 minutes of total data, the initial separation looked promising; there were distinct clusters for the major gestures and minimal overlap. Each iteration of data collection and feature tuning helped improve separation and overall model clarity.
For training, I used:
50 epochs
Learning rate: 0.00005
Architecture: 256 → 64 → 32 → 4 dense layers
The learning rate was intentionally conservative. I found that higher rates caused the model to overshoot and plateau early. Additionally, the gradual reduction in layer width (256→64→32) helped the model compress information effectively without overfitting.
Each training run became a feedback loop: inspect the feature space, adjust windowing or architecture, and retrain. Through this process, I found that spectral-domain features combined with a simple dense network provided the best balance between accuracy and computational efficiency for my embedded target.
With that in mind, our exported TF-Lite model came out to an estimated:
1 ms inference time
3.7k peak ram usage
76.3k flash usage
This was acceptable given that we had chosen a great microcontroller to run these types of models. So, we were able to export this to as Cube.MX CMSIS-PACK. This is a very well-built package that includes our models and some other HAL to allow us to not have to re-write our own inferencing pipeline.
Once our tflite model has been exported from from Edge Impulse, we can load the model and the CMSIS pack into STM32 so that we can do our inferencing.
If you recall our state machine, when we receive our IMU packet, we would bypass any inferencing and send it straight to our recording data pipeline (we comment out the processing functions) . However, when training is finished, we can continue processing as normal. So, what does processing mean?
We trained our model on a specific window size, window stride, frequency, and spectral features from our impulse. If we want to have accurate inferencing, we need to inference with those features. Our PROCESSING_ML step does exactly that. We first have to manage our data looking similar to the window size and stride our model expects.
To correctly get the proper window size/stride, our step_update_window manages a sliding buffer.
The window_push function inside of step_update_window handles a buffer of N_FRAME by N_AXES. Where axes is the number of input features (12 IMU) and the frame is the number of time steps the model looks at at once. Our window buffer does exactly that; it holds a sample of 13 data frames. When full, we drop the oldest data piece and add a new one to the buffer, continually pushing data to the model. This leads us to the 'step_infer_or_reuse' function
The step_infer_or_resuse function begins once we know that a full window buffer exists. If that is the case, we also make sure we aren't running the buffer every CPU cycle. The EI_CLASSIFIER_SLICE_SIZE tells us how many new frames must arrive before we run the model again.
Next we built the Edge Impulse signal . Where the key objects are:
ei::signal_t signal → a struct for EI input data
raw_get_data() → a callback that gives the runtime chunks of the data on demand
EI_CLASSIFIER_DSP_INPUT_FRAME_SIZE → how many samples the model expects (N_FRAMES * N_AXES)
run_classifier(signal, …) → runs DSP + the NN using that signal. run_classifier basically completes:
dsp_start(signal)
- Calls signal.get_data(offset, length)
- Applies DSP transforms (FFT, filters, scaling)
neural_net_infer(feature_vector)
- Runs the trained model
fills ei_impulse_result_t:
- classification[i].value = probability
Now that we have our inference from run_classifier, the final step is to get our best score. We do this by getting the highest probability and second-highest probability from the classification result. However, to get even more 'accurate' results, we gate the potential label behind a minimum threshold and a max. discrepancy between the best and second-best label (this avoids classification that may be too close between two labels).
Ideally, the raw scores would have been enough, but I found that, at the moment, there was too much 'noise' coming from the results. So, instead of returning the first result, we also keep a small buffer of results. If the majority in that buffer is a certain label, we, finally, return that as the best result.
Lastly, we send the packet and the result once again back to the XIAO for computer communication (action is not yet implimented).
For the case, I opted for a two-finger design. Since this was a dev board, I knew two fingers would be more comfortable to account for the larger size of the PCBA. Additionally, I made a lid and open spaces for easy programming, debuggers, USB-C connection, and the boot0 header. Lastly, chose a circular shape to match the PCB while also attempting to make it look a bit sleeker than it was.
Since this is V1 of the project, success consisted of establishing a data pipeline, getting classification results to the PC, and creating an early working prototype. Overall, I am very pleased with V1 and cannot wait to get some real mouse movements with V2!