Arduino Movement Sensors with Adafruit Feather ESP32
Table of Contents
Published on: 2026-01-25
Introduction
After 13 years of using Marije Baalman's MiniBee sensors for our performance and teaching work, they finally gave up the ghost. The platform is no longer actively developed, and it relies on the zombie programming language Python 2.7 to work, which makes it very far from future proof. In short, we were desperately looking for an alternative…
At this point I decided that enough is enough. The time had come to roll up my metaphorical sleeves and stop depending on others to build our sensors for us. Luckily I have a direct line to an amazing bunch of nerds and artists dealing with these kinds of things on a daily basis that I could ask for advice from.
In the end I went for a setup based around Adafruit ESP32 Feather V2 and Arduino Modulino Movement sensors. To avoid having to drag around a wifi router whenever I want to use my sensors, I went for the ESP-NOW protocol (thanks, Fredrik Olofsson!). This makes it possible to use one ESP32 as a receiver connected to the laptop, and the other ESP32s to process and send the data from the movement sensors. The advantages of this setup is that the sensors send off data from the millisecond I plug them into a battery; it draws much less electricity than running data over the local network; and it seems (so far) stable. The potential downside is that the protocol only supports 250 bytes per data packet, but considering I only send out an ID, the x and y data of the accelerometer, and the battery status—a grand total of 24 characters—this is plenty enough. The theoretical limit of devices per receiver is 20. I am not planning on using more than 8-10 sensors at any given time, so this is not an issue. If I should need more, then I suppose it would be easy enough to add one more receiver to the mix.
Setting up the toolchain
The default option for the budding microcontroller programmer is the ArduinoIDE, which is a very decent tool indeed. However, I am an Emacs animal, and I don't really want to type in anything else, much less code in it. Luckily the Arduino ecosystem speaks C++, a language I already use to program visuals in openFrameworks (even though I would much rather program in Lisp). Combining this with the awesome power of LSP via Eglot and Platformio for setting up the projects, uploading code to the chips, and monitoring the output via the serial port, and voilá: a beautiful environment in which to program our little systems. The only real difference between the code of an ArduinoIDE project and a C++ one is that you have to #include <Arduino.h> at the top of your main files, and also make sure that you declare all functions before using them.
Creating a project
The first thing to do is to create a project. This is taken care of by platformio:
mkdir Sensors cd Sensors pio project init --board adafruit_feather_esp32_v2 --ide emacs
I just love that you can specify emacs as your IDE…
The next thing to do is configuring platformio to do the right thing. Since our boards will perform two different functions, they will require two different source files to be uploaded. In ArduinoIDE this would mean creating one project for the receiver, one project for the sensors, with a lot of unnecessary and bug-prone code duplication. We don't want that. Mads Kjeldgaard swooped to the rescue with this configuration, to be saved at the project root as platformio.ini (slightly changed for my purposes):
; PlatformIO Project Configuration File ; ; Build options: build flags, source filter ; Upload options: custom upload port, speed and extra flags ; Library options: dependencies, extra library storages ; Advanced options: extra scripting ; ; Please visit documentation for the other options and examples ; https://docs.platformio.org/page/projectconf.html [platformio] default_envs = receiver [common] platform = espressif32 board = adafruit_feather_esp32_v2 framework = arduino monitor_speed = 115200 build_unflags = -std=gnu++11 build_flags = -std=gnu++17 lib_deps = https://github.com/arduino-libraries/Arduino_Modulino ; ==== Receiver node ==== [env:receiver] extends = common build_src_filter = +<main_receiver.cpp> ; ==== IMU sensor node ==== [env:imu-sensor] extends = common build_src_filter = +<main_imu.cpp> [env:detect-mac] extends = common build_src_filter = +<detect_mac.cpp>
The next thing on our todo-list is to create the two different main
program files that this project requires in the src/ directory. The
file src/main_receiver.cpp deals with everything the receiver node
needs to do, the file src/main_imu.cpp deals with everything sensor.
Common code
Both of the main files share some code, so it makes sense to put this
all in a shared file that is later on #included in the separate files.
// common.hpp constexpr uint8_t receiver_mac[] = {0xF4, 0x65, 0x0B, 0x33, 0x2D, 0x40}; typedef struct imu_data { uint8_t id; float x; float y; float bat; } imu_data;
The imu_data struct is the data that will be sent from the sensor
nodes to the receiver node, and from there on to the client running on
the laptop.
Getting the MAC address
receiver_mac is the MAC address of the receiver node, which the sensor
nodes need to know in order to pair with the receiver. In order to get
this you will first have to upload the following code to the receive
node and write down the correct address:
// detect_mac.cpp /* Rui Santos & Sara Santos - Random Nerd Tutorials Complete project details at https://RandomNerdTutorials.com/get-change-esp32-esp8266-mac-address-arduino/ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. */ #include <Arduino.h> #include <WiFi.h> #include <esp_wifi.h> void readMacAddress(){ uint8_t baseMac[6]; esp_err_t ret = esp_wifi_get_mac(WIFI_IF_STA, baseMac); if (ret == ESP_OK) { Serial.printf("%02x:%02x:%02x:%02x:%02x:%02x\n", baseMac[0], baseMac[1], baseMac[2], baseMac[3], baseMac[4], baseMac[5]); } else { Serial.println("Failed to read MAC address"); } } void setup(){ Serial.begin(115200); while(!Serial); delay(1000); Serial.print("HEY"); WiFi.mode(WIFI_STA); WiFi.begin(); // WiFi.STA.begin(); Serial.print("[DEFAULT] ESP32 Board MAC Address: "); readMacAddress(); } void loop(){ Serial.print("[DEFAULT] ESP32 Board MAC Address: "); readMacAddress(); delay(1000); }
Upload by running:
pio run -e detect-mac -t upload --upload-port /dev/ttyACM0
In order to gawk at the serial data you can run
pio device monitor --baud 115200 --port /dev/ttyACM0
Replace the port with whatever port your board shows up on. Plug the resulting mac address into common.hpp.
Receiver code
The code for the receiver looks like this:
#include <Arduino.h> #include <esp_now.h> #include <WiFi.h> #include "common.hpp" // callback function that will be executed when data is received void OnDataRecv(const uint8_t * mac, const uint8_t *incomingData, int len) { // Unpack and print imu_data data; memcpy(&data, incomingData, sizeof(data)); Serial.print(data.id, 1); Serial.print(" "); Serial.print(data.x, 6); // 6 decimal places for float Serial.print(" "); Serial.print(data.y, 6); Serial.print(" "); Serial.print(data.bat); Serial.print("\n"); } void setup() { // Initialize Serial Monitor Serial.begin(115200); // Set device as a Wi-Fi Station WiFi.mode(WIFI_STA); // Init ESP-NOW if (esp_now_init() != ESP_OK) { Serial.println("Error initializing ESP-NOW"); return; } // Once ESPNow is successfully Init, we will register for recv CB to // get recv packer info esp_now_register_recv_cb(esp_now_recv_cb_t(OnDataRecv)); } // No need for anything in the loop function, as everything is taken care of by the // callback function void loop() { }
In order to upload it to the board, run:
pio run -e receiver -t upload --upload-port /dev/ttyACM0
Sensor node code
#include <Arduino.h> #include "Modulino.h" #include "common.hpp" #include <esp_now.h> #include <WiFi.h> // Create a ModulinoMovement ModulinoMovement movement; // ID, x, y, battery. The ID is a unique integer for each of the sensor nodes. imu_data data = { static_cast<uint8_t>(2), 0.0f, 0.0f, 0.0f }; esp_now_peer_info_t peerInfo; // callback when data is sent. I use this only for debugging. // When I am sure that the connection is fine I comment it out. void OnDataSent(const uint8_t *mac_addr, esp_now_send_status_t status) { Serial.print("\r\nLast Packet Send Status:\t"); Serial.println(status == ESP_NOW_SEND_SUCCESS ? "Delivery Success" : "Delivery Fail"); } // The accelerometer data for x and y is scaled to the range [-1.0 .. 1.0]. // I prefer it to be in the range [0..1] instead. float normalizeAccData(float in) { in += 1.0f; in /= 2.0f; // Ensure we never go outside the range return std::clamp(in, 0.0f, 1.0f); } void setup() { Serial.begin(115200); // baud rate // Set device as a wifi station WiFi.mode(WIFI_STA); // Init ESP-NOW if (esp_now_init() != ESP_OK) { Serial.println("Error initializing ESP-NOW"); return; } // Once ESPNow is successfully Init, we will register for Send CB to // get the status of Transmitted packet. Uncomment for debugging. // esp_now_register_send_cb(OnDataSent); // Register peer memcpy(peerInfo.peer_addr, receiver_mac, 6); // Use default channel peerInfo.channel = 0; peerInfo.encrypt = false; // Add peer if (esp_now_add_peer(&peerInfo) != ESP_OK){ Serial.println("Failed to add peer"); return; } // Initialize Modulino I2C communication Modulino.begin(); // Detect and connect to movement sensor module movement.begin(); } float prevXY[2] = { 0.0f, 0.0f }; void loop() { // Read new movement data from the sensor movement.update(); // Get acceleration values data.x = normalizeAccData(movement.getX()); data.y = normalizeAccData(movement.getY()); // Get battery levels const float rawADC = analogReadMilliVolts(A13); const float currentVoltage = (rawADC * 2.0f) / 1000.0f; data.bat = currentVoltage; // Calculate delta value to determine when to send out data const float delta = (fabs(data.x - prevXY[0]) + fabs(data.y - prevXY[1])) / 2.0f; prevXY[0] = data.x; prevXY[1] = data.y; // Send message via ESP-NOW only when the sensor is in motion. // This should extend the battery life significantly if (delta > 0.005) { esp_err_t result = esp_now_send(receiver_mac, (uint8_t *) &data, sizeof(data)); } delay(50); }
All in all, impressively few lines of code to get everything working! Now all you have to do is connect the receiver to your laptop, plug in the battery on the sensors, and use the sensor data!
Soldering
The Feather dev boards use the amazing StemmaQT connections, which means you can plug the sensors to the ESP32 without any soldering and start hacking immediately. However, the cable sticks out at an awkward angle, and the connector feels like an obvious point of failure for a sensor that will be used a LOT by a bunch of dancers. So in order to make it safer (and flatter) you still need to solder the connections. Luckily the connection scheme is dead simple:
GND -> GND 3V -> 3V SDA -> SDA SCL -> SCL
I am not an experienced wielder of the soldering iron, so I found this very nice article to help me out on that front: https://www.fastturnpcbs.com/blog/assembly/how-to-solder-a-pcb/.
The next step now is to wrap the sensor nodes in heat shrink tubing to protect them from dust, sweat, and other unpleasantries.
Common Lisp client side code
The final step to be able to integrate all of this into our performance setup without having to rewrite a ton of programs is to replace the code that deals with incoming data. Luckily this turned out to be relatively simple, with the added benefit of not having to deal with Python 2.7 middleware:
(let ((open-p nil)) (defun start-serial (&optional (port *serial-port*)) (setf open-p t) (setf *serial-port* port) (bt:make-thread (lambda () (serial:with-serial-device (device serial:serial-device-input :name port :baudrate 115200) (format t "Opening serial port ~a~%" port) (let ((stream (flexi-streams:make-flexi-stream device))) (loop while (and stream open-p) do (let ((line (read-line stream))) (if (= (length line) 24) (let ((id (parse-integer (subseq line 0 1)))) (set-x id (parse-float:parse-float (subseq line 2 10))) (set-y id (parse-float:parse-float (subseq line 11 19))) (set-bat id (parse-float:parse-float (subseq line 20))))))) (format t "Closing serial port ~a~%" port)) :name "Serial from sensor")))) (defun stop-serial () (setf open-p nil)))
This little jewel is now at the heart of the mb Common Lisp package that I use for keeping track of active sensors, calculating their average amount of movement energy, and simulating their movement when I don't have the sensors around but still want to make music with them.