In this article, we explore the M5 Stack Fire IoT development widget. It makes a fantastic platform for learning the Arduino framework and the ESP32 without having to wire a bunch of stuff.
Introduction
The M5 Stack Fire 2.6 is a powerful IoT widget in a small, practical form factor.
This is a great unit to prototype IoT code with.
The base can connect to Legos. It has an internal battery pack, and a detachable charging station but can be charged via USB C without it either way. The device includes a dual core 240MHz RISC processor, a 320x240x16bpp LCD screen, 16MB of flash space, 512kB of SRAM (~360kB usable), 4MB of PSRAM, 3 programmable buttons, 3 Grove expansion ports, 2 neopixel LED strips, an SD reader, a speaker and a microphone suitable for very simple noise, a gyroscope, a LoRA radio, and WiFi 2.4Ghz, and Bluetooth/BLE. This device can also be "stacked" with modules that add a wide range of functionality, at least in theory. In practice, I have yet to try any of said modules. They're a bit pricey and I'm more into DIY stuff than I am into plug and play turnkey systems.
The documentation also says this device contains a BMM150 magnometer (essentially a MEMS based compass) but I could not get it to work even using the factory code. I'm not sure it's present in the 2.6 version of the Fire, but it was in earlier versions. It's possible that my unit is defective. The documentation says it's address 0x10, but the code I've found of theirs puts it address 0x13. I've tried both. No dice. I guess I'm in trouble if I ever get lost in the woods with this thing.
I wasn't satisfied with the featureset of the code that ships with these devices so I went ahead and made my own. The LCD and led strips are now powered by htcw_gfx allowing for all of the featurey goodness of my graphics library. The entire thing is packaged in a Platform IO project allowing you to download it, copy it to your project folder, and use it as a template for new M5 Stack Fire projects. I have yet to support the power management or LoRa features of this device. You'll have to use M5's libraries for that at the moment, as this is a work in progress.
I've included several True Type/Open Type fonts with the project as embeddable headers, but you can use the header generator at the aforementioned link to convert most TTF and OTF files. You can also use it to embed JPGs as headers. I use fontsquirrel.com to find fonts.
We're going to walk through the process of using the provided project template to create a new application.
Getting Started with This Mess
We'll be using the Arduino framework, although it is possible to target the ESP-IDF with this device. The Arduino framework enjoys wide ranging device support and there's a lot of example code online for it, so it's what I recommend for development on the ESP32. In addition, display drivers for GFX currently perform best under Arduino.
We'll be using Platform IO to do our development. If you haven't installed it, do so now, as this project will not work with Arduino IDE. This is college level, and the Arduino IDE is junior high. It simply can't do the things we need it to do, like use GNU C++17. The process for installing it amounts to installing Python, installing Git, installing VS Code, and then using VS Code to install the Platform IO extension.
Once you do that, you should be able to download the empty project, unzip it, and open the folder in VS Code and Platform IO will eventually load itself and wrap its arms around your project. The first time you do this, it can take forever and a day as it's just downloading necessary components to make it work in the background - it's not really finished installing until it is run the first time. Some of these components are over 100MBs if my memory serves so depending on your Internet connection, make a sandwich.
Unbox your M5Stack Fire and plug it in to your PC with the provided USB data cable. Platform IO will automatically detect the COM port it registers on so long as you don't have other IoT devices plugged in.
The Empty Project Template
The empty project isn't so empty, but it's designed to get you up and running as quickly as possible.
Let's break it apart and examine the individual components.
platformio.ini
This file contains the master settings for your project. It's basically the "project file" for your project. In it contains things like the chipset you're using, the software framework, the dependencies, the COM port information, compiler settings, and everything else of that nature.
[env:m5stack-fire]
platform = espressif32
board = m5stack-fire
framework = arduino
upload_speed = 921600
monitor_speed = 115200
monitor_filters = esp32_exception_decoder
lib_ldf_mode = deep
lib_deps =
codewitch-honey-crisis/htcw_ili9341
codewitch-honey-crisis/htcw_mpu6886
codewitch-honey-crisis/htcw_w2812
codewitch-honey-crisis/htcw_button
build_unflags = -std=gnu++11
build_flags = -std=gnu++17
-DBOARD_HAS_PSRAM
-mfix-esp32-psram-cache-issue
This file usually shouldn't need to be modified by you. You can add dependencies using the PlatformIO IDE's Libraries feature under Quick Tasks. The dependencies that are there are for using the peripherals of the M5 Stack Fire, like the ILI9342C display and the W2812 Neopixel LED strips.
Font Headers
Under /include are several header files. These are embeddable True Type/Open Type fonts. You may include one or more of these in your project if you need to render fancy anti-aliased text**. We'll cover the details with an example later.
**Due to some sort of hardware issue and maybe a weird SPI wiring scheme by the folks at M5, anti-aliasing is extremely (read unacceptably) slow on this display. The way around that is to draw text with non-transparent backgrounds, or to draw the text to a bitmap and then draw that bitmap to the screen.
main.cpp
Under /src is our main file where the meat of the application lives. In this file, we do all the hardware setup and then we have some boilerplate code for tying the hardware together and initializing it, plus a little bit of example code which can be easily tossed.
Just so you can find your way around, let's briefly cover what's there. The following is all boilerplate except for the #include "Ubuntu.hpp"
line which includes our font. That line can be removed or replaced as necessary to suit your purposes.
#include <Arduino.h>
#include <SPIFFS.h>
#include <SD.h>
#include <mpu6886.hpp>
#include <tft_io.hpp>
#include <ili9341.hpp>
#include <w2812.hpp>
#include <htcw_button.hpp>
#include <gfx.hpp>
#include "Ubuntu.hpp"
using namespace arduino;
using namespace gfx;
constexpr static const uint8_t spi_host = VSPI;
constexpr static const int8_t lcd_pin_bl = 32;
constexpr static const int8_t lcd_pin_dc = 27;
constexpr static const int8_t lcd_pin_rst = 33;
constexpr static const int8_t lcd_pin_cs = 14;
constexpr static const int8_t sd_pin_cs = 4;
constexpr static const int8_t speaker_pin_cs = 25;
constexpr static const int8_t mic_pin_cs = 34;
constexpr static const int8_t button_a_pin = 39;
constexpr static const int8_t button_b_pin = 38;
constexpr static const int8_t button_c_pin = 37;
constexpr static const int8_t led_pin = 15;
constexpr static const int8_t spi_pin_mosi = 23;
constexpr static const int8_t spi_pin_clk = 18;
constexpr static const int8_t spi_pin_miso = 19;
using bus_t = tft_spi_ex<spi_host,
lcd_pin_cs,
spi_pin_mosi,
-1,
spi_pin_clk,
SPI_MODE0,
true,
320 * 240 * 2 + 8, 2>;
using lcd_t = ili9342c<lcd_pin_dc,
lcd_pin_rst,
lcd_pin_bl,
bus_t,
1,
true,
400,
200>;
What we've done above is give constants for pertinent pin assignments and then initialized the display over the SPI bus, enabling DMA transfers in the process which allows for using GFX draw::bitmap_async<>()
to spit bitmaps to the display in the background, returning immediately after the call - an advanced technique to be sure, but available when it's needed.
Next we declare some aliases to get at the 16-bit named colors for our display and the 24-bit named colors for our Neopixel LED strips. After that, we declare instances of all of our hardware:
using color_t = color<typename lcd_t::pixel_type>;
using lscolor_t = color<typename w2812::pixel_type>;
lcd_t lcd;
mpu6886 gyro(i2c_container<0>::instance());
w2812 led_strips({5,2},led_pin,NEO_GBR);
button<button_a_pin,10,true> button_a;
button<button_b_pin,10,true> button_b;
button<button_c_pin,10,true> button_c;
Above, you'll see declared the LCD display lcd
, the MEMS accelerometer/gyroscope gyro
, the Neopixel strips led_strips
, and finally, the 3 buttons, button_a
, button_b
, and button_c
.
Next is our initialization routine for the M5 Stack which includes a minimal boot screen during startup:
void initialize_m5stack_fire() {
Serial.begin(115200);
SPIFFS.begin(false);
SD.begin(4,spi_container<spi_host>::instance());
lcd.initialize();
led_strips.fill(led_strips.bounds(),lscolor_t::purple);
lcd.fill(lcd.bounds(),color_t::purple);
rect16 rect(0,0,64,64);
rect.center_inplace(lcd.bounds());
lcd.fill(rect,color_t::white);
lcd.fill(rect.inflate(-8,-8),color_t::purple);
gyro.initialize();
pinMode(led_pin, OUTPUT_OPEN_DRAIN);
led_strips.initialize();
button_a.initialize();
button_b.initialize();
button_c.initialize();
}
The boot screen is not cleared by the initialization code, allowing for you to extend the boot time through your own code if it takes additional time to start up.
Next is the code you modify:
char button_states[3];
void buttons_callback(bool pressed, void* state) {
Serial.printf("Button %c %s\n",*(char*)state,pressed?"pressed":"released");
}
void setup() {
initialize_m5stack_fire();
button_states[0]='a';
button_states[1]='b';
button_states[2]='c';
button_a.callback(buttons_callback,button_states);
button_b.callback(buttons_callback,button_states+1);
button_c.callback(buttons_callback,button_states+2);
lcd.fill(lcd.bounds(),color_t::black);
const char* m5_text = "M5Stack";
constexpr static const uint16_t text_height = 80;
srect16 text_rect;
open_text_info text_draw_info;
const open_font &text_font = Ubuntu;
text_draw_info.text = m5_text;
text_draw_info.font = &text_font;
text_draw_info.scale = text_font.scale(text_height);
text_draw_info.transparent_background = false;
text_rect = text_font.measure_text(ssize16::max(),
spoint16::zero(),
m5_text,
text_draw_info.scale)
.bounds()
.center((srect16)lcd.bounds())
.offset(0,-text_height/2);
draw::text(lcd,text_rect,text_draw_info,color_t::gray);
draw::line(lcd,
srect16(text_rect.x1,text_rect.y1,text_rect.x1,text_rect.y2)
.offset(80,0),
color_t::white);
const char* fire_text = "Fire";
text_draw_info.text = fire_text;
text_rect = text_font.measure_text(
ssize16::max(),
spoint16::zero(),
fire_text,text_draw_info.scale)
.bounds()
.center((srect16)lcd.bounds())
.offset(0,text_height/2);
draw::text(lcd,text_rect,text_draw_info,color_t::red);
led_strips.fill({0,0,4,0},lscolor_t::red);
led_strips.fill({0,1,4,1},lscolor_t::blue);
}
void loop() {
button_a.update();
button_b.update();
button_c.update();
}
After boot, it displays M5|Stack Fire as shown at the top of the article. It also dumps button presses and releases to the serial port. All of this code can be replaced with your own code.
main.cpp Take Two
Starting at // for the button callbacks
delete everything.
Go up to the top and change #include "Ubuntu.hpp"
to #include "Robinette.hpp"
.
Paste the following into the bottom of the source file:
const char* text = "hello!";
const open_font &text_font = Robinette;
constexpr static const uint16_t text_height = 125;
srect16 text_rect;
open_text_info text_draw_info;
const rgb_pixel<16> colors_a[] = {
color_t::red,
color_t::orange,
color_t::yellow,
color_t::green,
color_t::blue,
color_t::purple
};
constexpr const size_t color_count_a = sizeof(colors_a)/sizeof(rgb_pixel<16>);
const rgb_pixel<16> colors_b[] = {
color_t::cyan,
color_t::pink,
color_t::white,
color_t::pink
};
constexpr const size_t color_count_b = sizeof(colors_b)/sizeof(rgb_pixel<16>);
const rgb_pixel<16> colors_c[] = {
color_t::red,
color_t::white,
color_t::blue,
};
constexpr const size_t color_count_c = sizeof(colors_c)/sizeof(rgb_pixel<16>);
int color_height;
unsigned int color_offset;
size_t color_count;
const rgb_pixel<16>* colors;
uint32_t led_strip_ts;
uint32_t led_strip_offset;
using frame_buffer_t = bitmap_type_from<lcd_t>;
uint8_t* frame_buffer_data;
frame_buffer_t frame_buffer;
void set_colors(int i) {
switch(i) {
case 0:
colors = colors_a;
color_count = color_count_a;
break;
case 1:
colors = colors_b;
color_count = color_count_b;
break;
default:
colors = colors_c;
color_count = color_count_c;
break;
}
color_height = lcd.dimensions().height/color_count;
}
void setup() {
initialize_m5stack_fire();
led_strip_ts=0;
led_strip_offset = 0;
color_offset = 0;
frame_buffer_data = (uint8_t*)ps_malloc(frame_buffer_t::sizeof_buffer(lcd.dimensions()));
if(frame_buffer_data==nullptr) {
Serial.println("Out of memory.");
while(true) {delay(10000);}
}
button_a.callback([](bool value,void*state){
if(value) {
Serial.println("a");
set_colors(0);
}
},nullptr);
button_b.callback([](bool value,void*state){
if(value) {
Serial.println("b");
set_colors(1);
}
},nullptr);
button_c.callback([](bool value,void*state){
if(value) {
Serial.println("c");
set_colors(2);
}
},nullptr);
frame_buffer = create_bitmap_from(lcd,lcd.dimensions(),frame_buffer_data);
set_colors(0);
rect16 r(0,0,lcd.bounds().x2,color_height-1);
frame_buffer.fill(r,colors[0]);
for(size_t i = 1;i<color_count;++i) {
r.offset_inplace(0,color_height);
frame_buffer.fill(r,colors[i]);
}
draw::bitmap(lcd,lcd.bounds(),frame_buffer,frame_buffer.bounds());
text_draw_info.text = text;
text_draw_info.font = &text_font;
text_draw_info.scale = text_font.scale(text_height);
text_rect = text_font.measure_text(ssize16::max(),
spoint16::zero(),
text,
text_draw_info.scale)
.bounds()
.center((srect16)lcd.bounds());
}
void loop() {
button_a.update();
button_b.update();
button_c.update();
rect16 r(0,
(color_height-1+color_offset)%lcd.dimensions().height,
lcd.bounds().x2,
(color_height-1+color_offset)%lcd.dimensions().height);
frame_buffer.fill(r,colors[0]);
for(size_t i = 1;i<color_count;++i) {
r.offset_inplace(0,color_height);
r.y1=r.y2=(r.y1%lcd.dimensions().height);
frame_buffer.fill(r,colors[i]);
}
draw::text(frame_buffer,
text_rect,
text_draw_info,
color_t::black);
draw::bitmap(lcd,
lcd.bounds(),
frame_buffer,
frame_buffer.bounds());
uint32_t ms = millis();
if(ms>=led_strip_ts+250) {
led_strip_ts = ms;
draw::suspend(led_strips);
for(int y = 0;y<led_strips.dimensions().height;++y) {
for(int x = 0;x<led_strips.dimensions().width;++x) {
draw::point(led_strips,
point16(x,y),
colors[(led_strip_offset+
(x+(y*led_strips.dimensions().width)))
%color_count]);
}
}
draw::resume(led_strips);
++led_strip_offset;
}
++color_offset;
}
The video below shows you what this does. We're cycling through one of three palettes, with both the screen and the LED strips, while displaying some text. When you press a button, the palette changes. Note that we use a frame buffer in PSRAM to do all of our drawing to, and then we blt that to the display periodically. This keeps flicker down, and also allows the font to be properly anti-aliased without killing performance due to the issue I mentioned with the display on this device.
Conclusion
That's all there is to it. Now take that M5 Stack Fire, go forth and create!
History
- 14th July, 2022 - Initial submission