Dust sensor data representation

The idea of using a HM3301 Laser dust sensor was to make a data representation that instead of boring numbers show a more visual appealing image representing particles and their size.
For that we though about making a read each 2 minutes and use a bi-stable display with 4 grays.
Components used:

The build work!
First you should measure the ESP32 board that you are going to use. Same we did with the HM3301 sensor 3D Model and we also uploaded it to Thingiverse.

That way it’s easier to place in the 3D design and move the small holders with 2mm holes (for the screws) to secure your board. You know, when you start using hot glue everywhere, is when it starts lookin’ bad :)
For that reason we provided the Blender file so you can use a free 3D modeling program to move things around as you like. Then you can simply switch to Wireframe view with key Z select with B (Bounding box) all what you want to export and export to STL.

This small article is focused on the build and Blender itself is a tool that is not for the faint of the heart, but is a nice and powerful 3D program, that in my case made sense to spend days learning how to tinker with it.

After 3D printing the bottom case you can start placing the parts. But it makes a lot of sense to test all this outside the box first, so first we need to connect the sensor that is easy since it’s 2 wire I2C (Plus power and SET pin, that on low makes the sensor sleep)

# Default I2C (No need for pull-ups are already in the sensor)
# Note we use 14 but could be any output PIN

With those in place we should be able to already flash a program and check the measurements. Please note that SET pin should be on HIGH in order for the sensor to be powered on (Bring the small fan close to your ear, you should hear it!)
For that we created our HM3301 component since there was no ESP32 IDF component, you can find it here:

You can already link this as a submodule in the components folder or for an easier test, just download our epaper-weather-station project and their linked components using the develop branch:

git clone –branch develop –recursive https://github.com/martinberlin/epaper-weather-station.git

–recursive will also pull linked submodules making it really easy to have everything available at once.
Updating the main/CMakeLists.txt you can point the build process to the file you want to build as your main entry point. And in the main/tests directory we left an hm3301 test program.

If that goes as expected then doing a: idf.py flash monitor
You should see sensor readings in the Serial output.
Congratulations, your Dust sensor seems to be measuring particles.

Now let’s go ahead and connect the epaper display!
Make sure that you connect the SPI adapter DESPI-CO3 and secure it to the epaper with some nice tape. I’ve used also here 2 small spots of hot glue but is not recommended, you might damage that nice display (Although I didn’t since I wait some seconds until is not super hot and I apply 2 small spots)
The idea is just to avoid making force over the sensible FPC flexible cable.
Once that is done you can start the SPI + power wiring to the ESP32 board:


That are IOs we used. For MOSI and CLOCK we usually use the ESP32 defaults. But you could of course use the GPIO matrix to route this signals to any output pins.
If all that is good connected and also the 3.3V and GND pins are perfectly connected, and tested with the multimeter that in fact are powering the epaper…then this should display something!
Just try our next example program:

With particle-draw.cpp you can test a program that reads from HM3301 and displays some random circles showing how many PM1, PM2.5 and PM10 particles are there.

New Cinwrite SPI HAT for IT8951 parallel epaper controllers

At the moment only available in Tindie Fasani Corporation store this PCB mission is to provide:

  • ESP32S3 Espressif MCU with 2MB of external RAM
  • WiFi
  • BLE
  • DS3231 real time clock and small CR1220 coin battery to keep time
  • Fast 40Mhz SPI
  • 3V to 5V step-up (And a way to enable this boost converter)
  • 3.7v LiPo battery charger

This Cinwrite PCB is open source Hardware that you can explore and even adapt to your needs.

The price is 45 USD since I only made 5 and otherwise it will be impossible to cover the costs. But it might go lower if we can make a budget version without Bluetooth, that is, provided there is some interested parties in using it.
This along with DEXA-C097 IT8951 controller sold by GoodDisplay, and fabricated by CINREAD, can be a very powerful option to use very fast 8-bit parallel displays, such as ED097OC4 or ED097TC2. There are many models available and all of them should work, they just have different contrast and VCOM voltage adjustments.
Making this board a very good option to control them and make a digital clock for a store, or add sensors in it’s dedicated I2C connector, such as Humidity, CO2 air quality or anything that can be plugged in this super fast MCU from Espressif.

LVGL to design UX interfaces on epaper

lv_port_esp32-epaper is the latest successful attempt to design UX in C using Espressif ESP32.
If you like the idea please hit the ★ in my repository fork.
What is LVGL?

LVGL stands for “Light and Versatile Graphics Library” and allows you to design an object oriented user interface in supported devices. So far it supports mostly TFT screens and only some slow SPI epapers where supported. My idea is to add driver support so it works also in fast parallel epapers.

That is Lilygo EPD47. Parallel epaper with ESP32 WROVER and I2C touch interface that can be found in Aliexpress

The main idea is to use a bridge driver that pushes the pixels to EPDiy component using the set_px_cb and the flush callbacks in order to render the layouts on the supported epapers. This will have a performance hit but it will also allow us to draw UX interfaces in parallel epapers that are quite fast flushing partial refresh.
The development took about one month of research and many iterations until it became usable. I started with an easy choice since Lilygo sent me an parallel epaper as a gift once and I bough the rest in their official store. The idea is that this acts as proof-of-concept to demostrate that is possible and that it’s working as expected. It’s possible to design an UX directly in C and then using a controller like ESP32 you can directly interact with Home appliances such as lights or other devices, to control them or to read information such as sensors that can respond with short JSON messages to inform your epaper control board about temperature or other matters that you choose.

LILYGO EPD47 T5-4.7 inch E-Paper (Link goes to Lilygo official store)
LILYGO T5-4.7 inch E-paper ESP32 V3 version Capacitive touch (IC driver is called L58)

More demos and videos

Recommended reads

Using WiFi epapers to showcase digital art

Recently I stepped upon superrare.co that is a marketplace to collect and trade unique digital artworks. This and the fact that I follow Josh Katzenmeyer that is a member of this network made more aware of the fact I enjoy watching this artworks very much and got me interested about it.

My idea is very simple. Is just to make a gallery using big epaper displays or a combination of them with traditional prints. Maybe in different sizes and with different technologies (3 color, with and without gray-scales, etc)

This is just a very raw idea of how such an exposition room would like.
The main point is that is pure digital technology to showcase digital art. But also it’s adventage is that a small room with 5 epapers like we can see in the bottom of the picture, could have “rotating contents” that are updated every 4 or 5 minutes, giving the visitors the oportunity to see different artworks depending on how long time they want to stay in the room. Or even the invitation to press a button to load the next image.

Exposition room with mixed prints and epaper displays
Preview of a render in a 12.48 inches black/white 1304 x 984 pixels screen. Showcased art is Measured confinement and you can buy the original in superrare website.
Epaper model is a Waveshare electronics product but the display itself is manufactured by Good Display

Now as cool as the idea may sound there are a couple of limitations:

  • It’s not possible or limited to render animations since most maker big displays do not support partial updates.
    Or if they do is not well documented how to do it. However in smaller displays is supported and will be possible to do something like a GIF
  • Only black&white have 3 grays like the model exposed in the video
  • There are 3 color displays, in red or yellow versions, but those do not have grayscales neither partial updates
  • There is a 7 colors one but it’s too small and not documented enough for me to use it at the moment

And known advantages can be:

  • WiFi epapers so the content could be controlled from anywhere. A version that has an SD card reader in the controller is also feasible
  • Very low consumption so the installation can be cable-less provided it has a battery that has enough power to sustain the refreshes for days
  • Enable a user interaction. Example with a push Button, sensors like movement or for example infrared reading visitor temperature and interacting in some way with the visitors of the exposition.

C++ Firmware and WiFi microcontrollers to send the image buffer

I’ve been researching and making my own controllers for this epapers, based in the ESP32 family, as a component for Espressif ESP-IDF framework. The name of the component is Cale-IDF and this the WiKi entry for the epaper showcased in the video.

In order to support a dynamic generation of Bitmaps I created also an image generator Webservice that is called CALE.es and let’s you compose images with a simple admin backend.

New epaper component for ESP-IDF

E-paper component driver for the ESP-IDF framework and compatible with ESP32 / ESP32S2 can be found in this repository:


Codenamed Cal e-pe-de for the initials of E-Paper Display this is basically all what I’ve been doing the last weeks. Learning more about C++ object oriented coding and preparing a class that can be used to send graphic Buffers to different epaper displays. In order to do that I’m documenting on the go and building at the same time CALE-IDF that is our Firmware version for CALE.es but this time built on top of the Espressif IoT Development Framework (IDF)

The mission of this new component is to have a similar library for ESP-IDF that is easier to understand. If possible strictly meeting these requirements:

  • Extendable
  • Maintainable
  • Object-Oriented Programming with human-readable code and function names
  • Easy to add a new Epaper display drivers
  • Easy to implement and send stuff to your Epaper displays
  • Human-understandable
  • Well documented
  • ESP-IDF only (No Arduino classes)

Please find here the Wiki with the models that are already supported in the component:


Leave us a note if you want to try this. We can guarantee that for big epaper displays (>=400 pix. wide) it runs faster than GxEPD also supporting Adafruit GFX fonts and geometric functions. If you tried and it worked out for you please don’t forget to add a ✰ Star and spread the idea.

Trying out Waveshare E-Paper ESP32 driver board

In my pursuit to make the CALE displays as small as possible but also to learn how different boards interact with Epaper I got last week an Waveshare E-Ink ESP32 driver board.

ESP32 Waveshare Epaper driver

Not all the boards need to use the default SPI GPIOs of the ESP32 and this was one of this cases. Opening the documentation, the first check was to see that Waveshare used this PINS:

#define PIN_SPI_SCK  13  // CLOCK ESP32 default 18
#define PIN_SPI_DIN  14  // MOSI ESP32 default: 23
#define PIN_SPI_CS   15
#define PIN_SPI_BUSY 25
#define PIN_SPI_RST  26
#define PIN_SPI_DC   27

So my first though was Ok nice but this won’t work per se using gxEPD library. Because this great library starts the SPI.begin() withouth parameters hence using ESP32 /ESP8266 default GPIOs for SPI communication. So what I did to test is very simple, I forked GxEPD:

And updated this part of the library to use defines that can be injected using Platformio build_flags:

Note that MISO is already defined in espressif32 Arduino framework.
So now we can inject the other 3 in platformio.ini and get the Waveshare example working:
build_flags =

If we want to test it with CALE we can also use the same defines to reference the EInk wiring in lib/Config/Config.h

// Waveshare ESP32 SPI
int8_t EINK_CS = 15;
int8_t EINK_BUSY = 25;
int8_t EINK_RST = 26;
int8_t EINK_DC = 27;
So it was not a lot of time to get it working with this simple modification. I find it great to inject defines using build_flags. It’s really straight-forward and in fact, if they are only simple defines, all configuration for a project could be done just in platformio.ini

The party breaker

So I was excited to get this working and avoid using this SPI adaptors that go between the ESP32 and the Epaper display…until I got the idea to connect it to my small 3.7 V Lipo that I use to test the ESP32’s with a Diode in between to lower 0.5 v. and found out how much it consumes in deepsleep:
That’s 13 mA per hour. Unless I’m missing something and there is something big that needs to be switched of before deepsleep this EPD driver will eat your battery and your kids for dinner.
So that was the low point of this test. If someone’s got a clue about this high consumption when sleeping please write something in the comments.
UPDATE: In the measurement below I forgot to put the diode in serie, so I was giving 3.8 v to the 3.3 pin. Don’t try it otherwise you may kill the ESP32.  Adding the diode it went down to 3.4 v and consumption of course went also down to 10 mA. But is still a lot for deepsleep. In good ESP32 boards like TinyPICO and others, deepsleep consumption is as low as 0.08 mA.
Video proof:

Reading an image bitmap file from the web using ESP8266 and C++

There are a couple of different ways to do it, but I wanted to do it after a simple image example, to understand a bit better how reading a stream from the web to get as far as the pixel information and send it to a display. As a reference then I started with /GxEPD Library :

There are a couple of basic things to understand when dealing with streams of information. One is that the information comes on chunks specially if there is a large file, then buffering whatever is coming is essential if you want to read from it. The first examples I did without buffering just filled the 7.5 E-ink display of separated lines, that only resembled part of the web screenshot I was going to send it.

So how comes an image you request from the web then ?

First of all like any other web content there is a request made to an endpoint to whatever script or API that delivers the image. This part of the code is well reflected here:

String request;
  request  = "GET " + image + " HTTP/1.1\r\n";
  request += "Accept: */*\r\n";
  request += "Host: " + host + "\r\n";
  request += "Connection: close\r\n";
  request += "\r\n";

  if (! client.connect(host, 80)) {
    Serial.println("connection failed");
  client.print(request); //send the http request to the server

In this case is a get Request. Then in the case of reading a Windows BMP image that is one of the easiest formats to read, the first thing is to check for the starting bits, that for a .bmp image file are represented by 2 bytes represented by HEX 0x4D42

But before that, when you send a Request and the server replies with a Response, it comes with the headers. For example it looks something like this:

HTTP/1.1 200 OK
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:61.0)
Accept: text/html

(And some more that I will spare here ending at the end with an empty line only with “\r” known as Carriage return)

Then after this the image should start. So there are two choices:

1 To make something that loops reading the first lines discarding the headers and then attempts to read the 2 starting bytes of the image

2 To read from the start including the headers and scan this 2 bytes until we find 4D42 that represent the start of the image

Between the two I prefer the first since it looks cleaner. If we where to take the second one for this image it will look like this, note is a 4-bit bmp:

5448 5054 312F 312E 3220 3030 4F20 D4B 440A 7461 3A65 5720 6465 202C 3130 4120
6775 3220 3130 2038 3131 343A 3A38 3334 4720 544D A0D 6553 7672 7265 203A 7041
6361 6568 322F 342E 312E 2036 4128 616D 6F7A 296E 4F20 6570 536E 4C53 312F 302E
312E 2D65 6966 7370 5020 5048 372F 302E 332E D30 580A 502D 776F 7265 6465 422D
3A79 5020 5048 372F 302E 332E D30 430A 6E6F 656E 7463 6F69 3A6E 6320 6F6C 6573
A0D 7254 6E61 6673 7265 452D 636E 646F 6E69 3A67 6320 7568 6B6E 6465 A0D 6F43
746E 6E65 2D74 7954 6570 203A 6D69 6761 2F65 6D62 D70 D0A 310A 3065 3637 A0D
4D42 ->BMP starts here. File size: 122998
Image Offset: 118
Header size: 40
Width * Height: 640 x 384 / Bit Depth: 4
Planes: 1
Format: 0
Bytes read:122912

Then as we can see in this example they come as the starting bits the image headers itself that are readed with this part of code:

// BMP signature
if (bmp == 0x4D42)
    uint32_t fileSize = read32();
    uint32_t creatorBytes = read32();
    uint32_t imageOffset = read32(); // Start of image data
    uint32_t headerSize = read32();
    uint32_t width  = read32();
    uint32_t height = read32();
    uint16_t planes = read16();
    uint16_t depth = read16(); // bits per pixel
    uint32_t format = read32();
uint16_t read16()
  // Reads 2 bytes and returns then
  uint16_t result;
  ((uint8_t *)&result)[0] = client.read(); // LSB
  ((uint8_t *)&result)[1] = client.read(); // MSB
  return result;

uint32_t read32()there
  // Reads 4 fucking bytes
  uint32_t result;
  ((uint8_t *)&result)[0] = client.read(); // LSB
  ((uint8_t *)&result)[1] = client.read();
  ((uint8_t *)&result)[2] = client.read();
  ((uint8_t *)&result)[3] = client.read(); // MSB
  return result;

In there comes a very important 2 bytes of information and without it is impossible or I just couldn’t find out how to read the pixels, and that’s Image Offset: 118 which means at byte 118 the image information starts. Also Depth that represents how many bits represents one single pixel. So in 1 bit, we can store a black and white image, and if we want full RGB then we need 24 bits per pixel, also 1 byte for each color (Red, Green and Blue)
Our dear Wikipedia says about this:

For an uncompressed, packed within rows, bitmap, such as is stored in Microsoft BMP file format, a lower bound on storage size for a n-bit-per-pixel (2n colors) bitmap, in bytes, can be calculated as:

size = width • height • n/8, where height and width are given in pixels.

So there we have then the Image Offset: 118, but to get to read this headers, we already got from the client 32 bytes. Then we need to make the difference and start reading the image:

// Attempt to move pointer where image starts
client.readBytes(buffer, imageOffset-bytesRead);

That should be it, then we need to read every row up to the reported width in our example 640, inside of a height loop of 384 pixels. And then read each pixel taking in account the pixel depth. In the code example this looks a bit rough around the corners:

    if ((planes == 1) && (format == 0 || format == 3)) { // uncompressed is handled
      // Attempt to move pointer where image starts
      client.readBytes(buffer, imageOffset-bytesRead);
      size_t buffidx = sizeof(buffer); // force buffer load

      for (uint16_t row = 0; row < height; row++) // for each line
        uint8_t bits;
        for (uint16_t col = 0; col = sizeof(buffer))
            client.readBytes(buffer, sizeof(buffer));
            buffidx = 0; // Set index to beginning
          switch (depth)
            case 1: // one bit per pixel b/w format
                if (0 == col % 8)
                  bits = buffer[buffidx++];
                uint16_t bw_color = bits & 0x80 ? GxEPD_BLACK : GxEPD_WHITE;
                display.drawPixel(col, displayHeight-row, bw_color);
                bits <<= 1;

            case 4: // was a hard word to get here
                if (0 == col % 2) {
                  bits = buffer[buffidx++];
                bits <<= 1;
                bits < 0x80 ? GxEPD_WHITE : GxEPD_BLACK;
                display.drawPixel(col, displayHeight-row, bw_color);
                bits <<= 1;
                bits < 0xFF  / 2) ? GxEPD_WHITE : GxEPD_BLACK;
                display.drawPixel(col, displayHeight-row, bw_color);
                bytesRead = bytesRead +3;
        } // end pixel
      } // end line

And I still have an issue that still didn't found why it does not work. This code works good and I can see images in 4-bits and 24-bits but it hangs on 1-bit image.
It's something about the headers, using the point 1 described before, also discarding headers the 1-bit image works. But not the other depths (4/24)
It's maybe some basic thing about how the byte stream comes that I'm not getting or I'm simply missing something stupid enough not to get around it.
There are other better examples on ZinggJM Repositories that deal much better with the buffering and other aspects, where the BMP reading truly works. But sometimes I like to understand the stuff and fight with it, before implementing something, since it's only way to learn how stuff works.
Have you ever though how far we are that every OS and every Browser have the resources to read almost any existing Image or Video format ? How many Megabytes of software is that ? ;)
That's what I love about coding simple examples in C++ on the Espressif chips. That you need to go deep, there is no such a thing of ready made json_decode or do-whatever libraries as in PHP. You need to read it from the bits. But the cool thing is that if you get around it, then you have a grasp of what is need to be done to read in this case a very simple Bitmap Format image. I cannot imagine how to read a compressed JPG or a PNG, I think for that yes, I will put my head down and use some library.
UPDATE: I found out after about 4 hours fight why it is. And it's the fact that I'm reading the bytes in chunks of 2. Reading them one by one and adding lastByte in the comparison to check them then it works for both 1 and 4 bits images. I can post the solution here if someone is interested, but if not, I will keep it as is to avoid making it a boring long read.