Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / IoT

TTGO Fan Take 2

5.00/5 (8 votes)
10 Jan 2023MIT20 min read 7.2K   64  
A far more functional fan controller than before, and some powerful programming techniques
Last time, we made a simple fan controller with a TTGO. This time, we add more functionality, more user interface feedback, and a PC app that synchronizes with it all. In doing so, we'll tour the htcw IoT ecosystem and a dirty way to create maintainable binary wire protocols between C# and C/C++.

TTGO Fan Controller

Introduction

Last time, we built and walked through a simple fan controller. This time, we expand it by adding a richer UI and a connectible PC application.

This is quite a bit more complicated than before, so I decided to make it a separate article.

As you can see from the title photo, there's a lot more here. What's not shown is the small connectible PC utility that mirrors the functionality and synchronizes with the TTGO.

We're going to explore some cool tech, like alpha blending, double buffering, and a way to serialize structs over serial such that you can read and write the same structs from C# and C++ and the wire format is the same.

We have a lot going on this time around. The wiring and prerequisites are however, almost the same. You will need Visual Studio in order to compile the PC end.

Prerequisites

  • You'll need a 12V PWM based 4-pin fan - the kind with the tach feedback, and beware cheapo fans that use a fake tach that always reports the same RPM.
  • You'll need a level shifter, probably. In theory, you might be able to get away without one, but I recommend using it, and not running a circuit outside of its operating specs.
  • You'll need a TTGO Display T1.
  • You'll need a 12V >= 0.2A power supply.
  • It helps to have some double ended male dupont connectors (long on both sides) to make connection points for the fan and 12V power supply alligator clips, though you can get by with solid core wire maybe.
  • You'll need one of those 2 line quadrature encoders that you can find for Arduino.
  • You'll need VS Code with the PlatformIO extension installed.
  • You'll need some patience to follow wiring.txt and wire the whole thing up. Do this carefully, because you're dealing with 12V and so if you wire it into anything other than the fan, you'll fry stuff. I get extra antsy when sharing a ground, but it's okay for this circuit.
  • You'll need Visual Studio and the .NET Framework with C# installed.

Using This Mess

Using it is pretty simple. On the TTGO, you twist the encoder to change the RPM or PWM and either button to change the mode from setting the PWM or the RPM. If it's in RPM mode, the fan will automatically and continually adapt to try to hit the target RPM. Keep in mind the adaptive algorithm fails to adapt at RPMs that are below the fan's effective range.

On the PC side, if you run the application and choose the TTGO's COM port, it will start giving you RPM and PWM values on the progress bars, and you can change either the PWM or RPM using the trackbar. The radios change the mode, similar to the buttons on the TTGO. The TTGO will sync with PC app, but the app's trackbar will not sync with the TTGO due to a limitation of the WinForms API that would complicate the UI beyond what I wanted to present here.

Understanding This Mess

Let's tackle this in sections, because there are a lot of moving parts.

The TTGO

The TTGO uses my IoT ecosystem to do all of the work. It uses my fan controller, my encoder library, my button library, my TTGO library - all of the dependencies in this project other than the core Arduino framework are part of my ecosystem.

That was part of the point - to get you the reader, acquainted with it and what it can do.

It uses the TTGO library I made to pull in my graphics library, the device driver for the display and both buttons on the TTGO.

It also uses my encoder library for the knob.

It uses my fan controller library which also includes an adaptive algorithm for targeting a specific RPM even as environmental conditions change.

A significant part of this project is the serial communication it uses as an interface to the PC app. We use a trick with marshalling on the C# side to create a protocol that allows you to reconstitute the same struct in C++. Each struct is preceded by a command identifier indicating which struct follows.

Basically when it starts up, at least unless you change the code, it will default to detecting the fan RPM, which it does during initialization.

We have routines for displaying some centered text, and then one for displaying the entire status screen. We never draw directly to the display. Instead, we draw to a bitmap and send that to the display to eliminate flicker. This is known as double buffering.

Other than that, we continuously monitor the fan RPM and knob position, and update the screen as necessary once every tenth of a second. We also monitor the serial port for incoming commands and structures. In two cases, we set the values for the fan, and in the other case we send a message with all the pertinent fan and UI information in the response.

We'll cover the details in the next code section.

The PC

The PC WinForms contains a combo box for setting the COM port, a check box for connecting, two progress bars for reporting the RPM and PWM, and a couple of radios for changing the mode, and a track bar for setting the RPM or PWM depending on the mode.

Meanwhile, there's a timer which sends a request over the serial every tenth of a second to retrieve the fan information. On the received event, it takes that information and updates the UI. This code uses my serial extensions code to marshal structs over the serial connection.

Every time the trackbar changes, a new message is constructed and sent to the TTGO, setting the fan's RPM or PWM depending on the selected mode.

Coding This Mess

We've got a lot to get through, some of which we covered in the previous article, but we'll cover everything again just so the continuity isn't broken.

The Arduino TTGO Firmware

Here's the ini that sets everything up. Most of it is just boilerplate, except for the bit that pulls in the dependencies and updates the compiler version to GNU C++17.

platformio.ini

[env:ttgo-t1]
platform = espressif32
board = ttgo-t1
framework = arduino
monitor_speed = 115200
monitor_filters = esp32_exception_decoder
upload_speed = 921600
lib_ldf_mode = deep
lib_deps = codewitch-honey-crisis/htcw_ttgo
        codewitch-honey-crisis/htcw_encoder
        codewitch-honey-crisis/htcw_fan_controller
build_unflags = -std=gnu++11
build_flags = -std=gnu++17

Most of the meat is in main.cpp so we'll continue there.

main.cpp

First some boilerplate:

C++
// config constants
#define ENCODER_DATA 17
#define ENCODER_CLK 13
#define FAN_TACH 33
#define FAN_PWM 32
#define MAX_RPM NAN
//includes
#include <ttgo.hpp>
#include <encoder.hpp>
#include <fan_controller.hpp>
#include <interface.hpp>
// downloaded from fontsquirrel.com 
// and header generated with 
// https://honeythecodewitch.com/gfx/generator
#include <fonts/Telegrama.hpp>
static const open_font& text_font = Telegrama;

We have some defines for the pin constants, and then one for the fan's maximum RPM which is NAN for autodetect unless you want to fix it to a value.

After that, we include the stuff to support our hardware and an interface file that defines our serial protocol.

Note that we also included a font file and then declared an open_font reference to Telegrama. This object comes from the header just above it, and it's so we can easily change our font out to a different font by changing those two lines.

Now we declare the encoder and the fan controller:

C++
// hardware external to the TTGO
static int_encoder<ENCODER_DATA,ENCODER_CLK,true> knob;
static fan_controller fan(
    [](uint16_t duty,void* state){ ledcWrite(0,duty>>8); },
    nullptr,
    FAN_TACH, MAX_RPM);

Note the flat lambda in the fan_controller constructor. This is for the PWM callback such that it uses the internal PWM facilities of the ESP32 and writes an 8-bit value to the PWM generator. Different platforms will use a different method, or it's possible to use this to call external PWM generator hardware.

Now we set up the frame buffer. This is used as an intermediary draw destination/canvas which we draw to and then send to the display as a whole, all at once. This reduces flicker. The technique of drawing to an intermediary bitmap and then sending that to the display is known as double buffering.

C++
// the frame buffer used for double buffering
using frame_buffer_t = bitmap<typename lcd_t::pixel_type>;
static uint8_t frame_buffer_data[
    frame_buffer_t::sizeof_buffer(
        {lcd_t::base_width,lcd_t::base_height}
    )
];

Specifically we're declaring a bitmap type for the frame buffer, and then declaring an array, computing the size of it based on the size of the LCD display and the pixel type of the bitmap, which in this case is the same as the display. This is all a facility of htcw_gfx. The documentation for that library is here.

Now some temporary string holders:

C++
// temporary strings for formatting
static char tmpsz1[256];
static char tmpsz2[256];
static char tmpsz3[256];

These are declared globally because stack space is at a premium and it's best not to use more if it than you must. It's a common technique of mine to predeclare things as global I'd otherwise have to declare on the stack. These are used later with snprintf().

Now some more globals:

C++
// the current input mode
static int mode = 0; // 0 = RPM, 1 = PWM
// the target rpm to maintain
float target_rpm = 0;
// whether a redraw is required
static bool redraw = false;

First, the input mode is either RPM, or PWM which is switched using the TTGO buttons.

The target_rpm is the RPM we'd like to maintain or NAN if we're not using adaptive RPM targeting.

The redraw variable indicates if we need to redraw the screen.

Now we have some junk we use to keep track of things in the loop, primarily.

C++
// bookkeeping cruft
static float old_rpm=NAN;
static long long old_knob=-1;
static uint32_t ts=0;

The "old" values keep track of the previous RPM and previous knob position so we know if things need to be updated. The ts variable is a timestamp we use to implement a trivial timer inside loop().

Now we get into some of the good stuff. Let's start with drawing centered text:

C++
// draw text in center of screen
static void draw_center_text(const char* text, int size=30) {
    // finish any pending async draws
    draw::wait_all_async(lcd);

    // get a bitmap over our frame buffer
    frame_buffer_t frame_buffer(
        lcd.dimensions(),
        frame_buffer_data);
    // clear it to purple
    draw::filled_rectangle(
        frame_buffer,
        frame_buffer.bounds(),
        color_t::purple);

    // fill the text structure
    open_text_info oti;
    oti.font = &text_font;
    oti.text = text;
    // scale the font to the right line height
    oti.scale = oti.font->scale(size);
    // measure the text
    srect16 txtr = oti.font->measure_text(
                                ssize16::max(),
                                spoint16::zero(),
                                oti.text,
                                oti.scale).bounds();
    // center what we got back
    txtr.center_inplace((srect16)frame_buffer.bounds());
    // draw it to the frame buffer
    draw::text(frame_buffer,txtr,oti,color_t::white,color_t::purple);

    // asynchronously send the frame buffer to the LCD (uses DMA)
    draw::bitmap_async(
                    lcd,
                    lcd.bounds(),
                    frame_buffer,
                    frame_buffer.bounds());
}

We start by waiting for any potentially pending DMA operations to the LCD to finish. This is because our DMA buffer is the frame buffer itself, and we're about to draw to it. Doing so while a DMA transfer is in progress will cause display corruption at the very least, so we want to avoid that.

We didn't really need to use DMA here, but it does make things a bit more efficient, and I wanted to demonstrate how easy it is with htcw_gfx. Basically, you just have one extra call to make if you need to wait on anything you did prior. Initiating a background DMA transfer is just a matter of using draw::bitmap_async() or draw::batch_async().

Anyway, now we wrap frame_buffer_data[] with an instance of a bitmap<> template instantiation of concrete type frame_buffer_t. In simpler terms, we wrap our frame buffer array with a bitmap object. This wasn't done in the globals section because it's really not necessary. The bitmap object itself is extremely lightweight, and simply serves as a "view" into the pixel data array. It's entirely practical to create and toss bitmap objects as needed even in critical code paths. It's the pixel data memory itself that is heavy, not the bitmap that wraps it.

Next, we clear the bitmap by filling it with a purple rectangle. htcw_gfx does not clear bitmaps on creation, because sometimes it's not necessary, such as when the entire bitmap will be redrawn regardless. In order to prevent garbage, you must clear it yourself, often with draw::filled_rectangle() but you can also use the clear() or fill() methods on the bitmap itself. We would have had to do it anyway due to previous draws.

Now we begin filling an open_text_info structure with our font and text information. The font's scale() method takes a line height in pixels and converts it to a fractional scale of the font's huge native size. You can use it to render a font at a desired size.

Before we center the text, we must find out how big its area will be in pixels. We can use the font's measure_text() method to do so and we do that here. The first argument is the total layout area we have to work with. We just use the maximum size. The second argument is an offset into the layout area where we will start drawing. We just use (0,0). After that is the text itself and the scale. When we get the size back, we convert it to a rectangle using bounds(), which is a common method to get a bounding rectangle off of various htcw_gfx objects like paths, sizes, and draw targets.

Once we have the rectangle, we center it in place based on the bounds of the frame buffer, which means instead of returning a copy of the rectangle, centered like we would with center(), center_inplace() modifies the source rectangle itself. Any _inplace() method modifies the source object itself.

Now we draw the text itself passing in our destination and destination rectangle, the open text structure, and foreground and background colors. We didn't need to specify a background color here because we aren't drawing a background, but if we turned off transparent backgrounds in the open text structure we would need one so I just included it.

Finally, we initiate an asynchronous transfer of the frame_buffer to the lcd using draw::bitmap_async(). This method completes almost immediately, while the transfer continues in the background. This means your code can continue running while the transfer takes place using the magic of DMA. htcw_gfx is DMA aware on supporting platforms with supporting drivers. On setups that aren't DMA capable, the async methods are synchronous, but here we have DMA.

Now we have drawing the status screen. This is much like the above, except with more text and the additional of a graphical indicator bar which we'll explore. This is pretty long over all but most of the code in here we've already gone over the theory of above:

C++
// draw centered text in more than one area
static void draw_status(const char* text1, 
                        const char* text2, 
                        const char* text3, 
                        int size=30) {
    // finish any pending async draws
    draw::wait_all_async(lcd);

    // get a bitmap over our frame buffer
    frame_buffer_t frame_buffer(
        lcd.dimensions(),
        frame_buffer_data);
    // clear it to purple
    draw::filled_rectangle(
        frame_buffer,
        frame_buffer.bounds(),
        color_t::purple);

    // fill the text structure
    open_text_info oti;
    oti.font = &text_font;
    oti.text = text1;
    // scale the font to the right line height
    oti.scale = oti.font->scale(size);
    // measure the text
    srect16 txtr = oti.font->measure_text(
                                ssize16::max(),
                                spoint16::zero(),
                                oti.text,
                                oti.scale).bounds();
    // center what we got back horizontally
    txtr.center_horizontal_inplace((srect16)frame_buffer.bounds());
    // move it down 10 pixels
    txtr.offset_inplace(0,10);
    // draw it
    draw::text(
        frame_buffer,
        txtr,oti,
        color_t::white,
        color_t::purple);
    
    // set the next text
    oti.text = text2;
    // measure it
    txtr = oti.font->measure_text(
                        ssize16::max(),
                        spoint16::zero(),
                        oti.text,
                        oti.scale).bounds();
    // center it horizontally
    txtr.center_horizontal_inplace((srect16)frame_buffer.bounds());
    // offset 10 pixels from the bottom of the previous text
    txtr.offset_inplace(0,size+20);
    // draw it
    draw::text(
        frame_buffer,
        txtr,
        oti,
        color_t::white,
        color_t::purple);

    // draw the PWM/RPM indicator bar
    srect16 bar(10,txtr.y2,frame_buffer.dimensions().width-10,txtr.y2+size);
    draw::filled_rectangle(frame_buffer,bar,color_t::dark_slate_gray);
    bar.x2 = (bar.width()-1)*(fan.pwm_duty()/65535.0)+bar.x1;
    draw::filled_rectangle(frame_buffer,bar,color_t::yellow);
    bar.x2 = frame_buffer.dimensions().width-10;
    auto px = color<rgba_pixel<32>>::dark_orange;
    px.channel<channel_name::A>(127);
    bar.x2 = (bar.width()-1)*(fan.rpm()/fan.max_rpm())+bar.x1;
    draw::filled_rectangle(frame_buffer,bar,px);

    // set the final text
    oti.text = text3;
    // measure it
    txtr = oti.font->measure_text(
                        ssize16::max(),
                        spoint16::zero(),
                        oti.text,
                        oti.scale).bounds();
    // center it horizontally
    txtr.center_horizontal_inplace((srect16)frame_buffer.bounds());
    // offset 10 pixels from the bottom of the screen
    txtr.offset_inplace(0,frame_buffer.dimensions().height-size-10);
    // draw the text to the frame buffer
    draw::text(
            frame_buffer,
            txtr,oti,
            color_t::white,
            color_t::purple);

    // asynchronously send it to the LCD
    draw::bitmap_async(
                    lcd,
                    lcd.bounds(),
                    frame_buffer,
                    frame_buffer.bounds());
}

The only new thing here is the PWM/RPM indicator bar so let's talk about that.

It's actually composed of three filled rectangles. The first one is the background, which is a dark gray, so we draw that.

Next, we draw a yellow bar of a width based on the value of the fan's pwm_duty().

Now it gets interesting. We create a 32-bit RGB pixel with an alpha channel (rgba_pixel<32>) and set it to dark orange.

We then take the alpha channel and set it to 127 which is 50% of the maximum. This gives it 50% transparency, meaning 50% of the color beneath it will bleed through. That way, when we draw it over the previous bars, you'll be able to see everything regardless. Using that color, we draw the last bar.

Now for the TTGO button handling:

C++
// for the button
static void on_click_handler(int clicks, void* state) {
    // reduce the clicks to odd or even and set the mode accordingly
    mode = (mode+(clicks&1))&1;
    // reset the timestamp for immediate update
    ts = 0;
    if(mode==0) {
        // set the new RPM from the current RPM
        target_rpm = fan.rpm();
        if(target_rpm>fan.max_rpm()) {
            target_rpm = fan.max_rpm();
        }
        // set the knob's position to reflect it
        knob.position(((float)target_rpm/fan.max_rpm())*100);
    } else {
        target_rpm = NAN;
    }
    // force the loop to reconsider the knob position
    --old_knob;
    old_rpm = NAN; 
    redraw = true;
}

What we're doing here is changing the mode. The button supports multi-click such that if you press it rapidly, it fires the event once with the count of clicks in it. We don't care about that, we just want single click. The trick here is to reduce everything to even or odd and set the mode based on that. That reduces everything to a simple toggle.

If the mode is RPM, we have to reset the knob position so that it reflects the current RPM. This is because the RPM is variable as the fan operates and in any case is not the same as the PWM value.

We also range limit it to max_rpm() because it is possible for rpm() to be greater than that, especially in cases where you set the max rpm in the constructor yourself.

Finally, we set the old values to force everything to recompute, and set the redraw flag to make the screen update.

On to setup() where it all begins:

C++
void setup() {
    Serial.begin(115200);
    // init the ttgo
    ttgo_initialize();
    // landscape mode, buttons on right
    lcd.rotation(1);
    // set up the PWM generator to 25KHz, channel 0, 8-bit
    ledcSetup(0,25*1000,8);
    ledcAttachPin(FAN_PWM,0);

    // if indicated, fan.initialize() will detect the max RPM, so we 
    // display a message indicating that beforehand
    if(MAX_RPM!=MAX_RPM) {
        draw_center_text("detecting fan...",20);
    }
    // init the fan
    fan.initialize();
    // turn the fan off
    fan.pwm_duty(0);
    // display the max RPM
    snprintf(tmpsz1,
            sizeof(tmpsz1),
            "Max RPM: %d",
            (int)fan.max_rpm());
    draw_center_text(tmpsz1,25);
    // init the encoder knob
    knob.initialize();
    // set the button callbacks 
    // (already initialized via ttgo_initialize())
    button_a.on_click(on_click_handler);
    button_b.on_click(on_click_handler);
    // delay 3 seconds
    delay(3000);
}

I don't believe this requires much more explanation than the comments provide. The only wrinkle here is we set the button click handlers to the same handler, because they both do the same thing.

loop() is where the magic happens, and it's somewhat involved so we will cover it in several parts:

Serial Communication

This gets interesting:

C++
// look for incoming serial packets
while(Serial.available()>=sizeof(uint32_t)) {
    uint32_t cmd;
    // read the command
    if(sizeof(cmd)==Serial.read((uint8_t*)&cmd,sizeof(cmd))) {
        // read the appropriate message and 
        // update the system accordingly
        switch(cmd) {
            case fan_set_rpm_message::command: {
                fan_set_rpm_message msg;
                Serial.read((uint8_t*)&msg,sizeof(msg));
                target_rpm = msg.value;
                old_rpm = NAN;
                // recompute the knob position
                knob.position(((float)target_rpm/fan.max_rpm())*100);
                mode = 0;
            }
            break;
            case fan_set_pwm_duty_message::command: { 
                fan_set_pwm_duty_message msg;
                Serial.read((uint8_t*)&msg,sizeof(msg));
                target_rpm = NAN;
                fan.pwm_duty(msg.value);
                knob.position(((float)msg.value/65535.0)*100);
                mode = 1;
            }
            break;
            case fan_get_message::command: { 
                fan_get_message msg;
                Serial.read((uint8_t*)&msg,sizeof(msg));
                fan_get_response_message rsp;
                rsp.rpm = fan.rpm();
                rsp.pwm_duty = fan.pwm_duty();
                rsp.max_rpm = fan.max_rpm();
                rsp.target_rpm = target_rpm;
                rsp.mode = mode;
                cmd = fan_get_response_message::command;
                while(!Serial.availableForWrite()) {
                    delay(1);
                }
                Serial.write((uint8_t*)&cmd,sizeof(cmd));
                while(!Serial.availableForWrite()) {
                    delay(1);
                }
                Serial.write((uint8_t*)&rsp,sizeof(rsp));
            }
            break;
            default:
                // junk. consume what we didn't read
                while(Serial.available()) Serial.read();
            break;
        }
    } else {
        // junk. consume what we didn't read
        while(Serial.available()) Serial.read();
    }
}

First, we make sure there's at least one 32-bit integer in the stream. This is our command id, and we use it to determine what comes next, so if it's waiting we read it out of the stream, and then switch on it. In interface.hpp, we have all of these structures for the serial messages we can send and/or receive defined. They do not include the command id, but they do have a constant command, which indicates what the command id for that structure is supposed to be. We use that in our case lines. Note that we're {} bracketing each entire case block. This is because we declare variables under each case, and we only want them to be in scope for that case.

We declare the appropriate structure for the corresponding command, and then cast it to read it out of the stream as a series of bytes. We can then use it however we need to.

In the first two cases, we simply use it to set the RPM or the PWM of the fan, respectively.

The final non-default case is more involved. When it receives a message, it sends a message in response containing pertinent UI and fan information the PC app can use to synchronize itself. It works kind of like an HTTP request/response cycle does except binary, and over serial.

In the default case, we consume anything we don't recognize to make way for anything we do. This just keeps things from getting jammed up in case of bad data.

We do the same thing if we didn't get a proper command in the stream, as indicated after the else.

UI Code
C++
// give the fan a chance to process
fan.update();
// range limit the knob to 0-100, inclusive
if(knob.position()<0) {
    knob.position(0);
} else if(knob.position()>100) {
    knob.position(100);
}
// trivial timer
uint32_t ms = millis();
// ten times a second...
if(ms>ts+100) {
    ts = ms;
    // if RPM changed
    if(old_rpm!=fan.rpm()) {
        // print the info
        old_rpm = fan.rpm();
        redraw = true;
        
    }
}
if(redraw) {
    // format the RPM
    snprintf(tmpsz1,
                sizeof(tmpsz1),
                "Fan RPM: %d",
                (int)fan.rpm());
    // format the PWM
    snprintf(tmpsz2,
                sizeof(tmpsz2),
                "Fan PWM: %d%%",
                (int)(((float)fan.pwm_duty()/65535.0)*100.0));
    if(mode==0) {
        // format the target RPM
        snprintf(tmpsz3,
                sizeof(tmpsz3),
                "Set RPM: %d",
                (int)target_rpm);
    } else {
        // format the target PWM
        snprintf(tmpsz3,
                sizeof(tmpsz3),
                "Set PWM: %d%%",(int)knob.position());
    }
    draw_status(tmpsz1,tmpsz2,tmpsz3,25);
    redraw = false;
}
// if the knob position changed   
if(old_knob!=knob.position()) {
    if(mode==0) {
        // set the new RPM
        target_rpm = fan.max_rpm()*
            (knob.position()/100.0);
        fan.rpm(target_rpm);
    } else {
        // set the new PWM
        target_rpm = NAN;
        fan.pwm_duty(65535.0*(knob.position()/100.0));
    }
    // force redraw:
    old_rpm = NAN;
    old_knob = knob.position();
}
// we don't use the dimmer, so make sure it doesn't timeout
dimmer.wake();
// give the TTGO hardware a chance to process
ttgo_update();

Here, we let the fan update(), which involves recomputing the RPM and potentially adjusting the PWM to try to hit the target RPM, if applicable.

After that, we force the knob to clamp the position() between zero and one hundred, inclusive.

The next thing we do is see if the RPM changed. If so we indicate that we need to redraw the screen, but we use ts to make sure we don't do it more than ten times a second.

If we do have to redraw, we format the display strings with the RPM and PWM data using snprintf() and our temporary global strings from earlier. We then draw, and reset the redraw flag.

Finally, if the knob position has changed, we change the fan's rpm() or pwm_duty() depending on the mode. Then we force a redraw.

The rest is boilerplate for when we don't use a dimmer. It keeps the button callbacks firing and the screen's backlight on.

interface.hpp

This file essentially defines our serial protocol as a series of structures prefixed by command ids. It really couldn't be simpler, or at least that was the idea:

C++
#pragma once
#include <stdint.h>

struct fan_set_rpm_message final {
    constexpr static const uint32_t command = 1;
    float value;
};
struct fan_set_pwm_duty_message final {
    constexpr static const uint32_t command = 2;
    uint16_t value;
};

struct fan_get_message final {
    constexpr static const uint32_t command = 3;
    uint32_t dummy;
};

struct fan_get_response_message final {
    constexpr static const uint32_t command = 4;
    float max_rpm;
    float target_rpm;
    float rpm;
    uint16_t pwm_duty;
    uint8_t mode;
};

Notice we have a different constant command id for each structure.

The only real weirdness here is the dummy, and that's there because of a curiosity in terms of how C and C++ handle "zero length"/empty structures. They aren't zero length - sizeof() will return one, at least on an ESP32 using the Platform IO toolchain. I haven't dug into how universal that quirk is, but it means if you don't want to special case your struct reading and writing code you have to include a field. The dummy field serves that purpose.

It should also be noted that these structures use the default packing of four bytes for the ESP32 platform. This is important because we have to match it on the PC side in order for them to talk to each other. It's easier than it might seem. There's a trick to it which we'll explore further on.

Note also that we can create the C# equivalent of this file with a series of search and replaces over it, and I highly recommend that approach to cut down on bugs, but again, we'll get into that.

WinForms .NET PC Application

It will probably be easier if we can see the UI while we go over it:

Fan controller PC application

We'll start with the main form, pictured:

Main.cs

C#
SerialPort port;
FanGetResponseMessage response = default;
public Main()
{
    InitializeComponent();
    Show();
    RefreshPortList();
}

This basically starts up our form, and populates the COM port combo box. The form's members hold the active serial connection and the most recent fan information response.

C#
void RefreshPortList()
{
    PortCombo.Items.Clear();
    var ports = SerialPort.GetPortNames();
    foreach (var port in ports)
    {
        PortCombo.Items.Add(port);
    }

    PortCombo.SelectedIndex = 0;
}

This populates our combo box with COM ports and sets the combo to the first entry.

We have a timer that runs once every tenth of a second, creates and/or opens the COM port if required, and attaches the data received event if necessary. It then writes the command to request the fan information (three) and transmits the FanGetMessage structure which is defined in Interface.cs. It uses WriteStruct() which is an extension method defined in SerialExtensions.cs.

C#
private void FetchTimer_Tick(object sender, EventArgs e)
{        
    if(port==null)
    {
        port = new SerialPort(PortCombo.Text, 115200);
        port.DataReceived += Port_DataReceived;
    }
    if(!port.IsOpen)
    {
        port.Open();
    }
    FanGetMessage msg;
    msg.Dummy = 0;
    var ba = BitConverter.GetBytes((UInt32)3);
    port.Write(ba,0,ba.Length);
    port.WriteStruct(msg);
}

Next, we handle the DataReceived event which we use to retrieve fan information and update the UI:

C#
private void Port_DataReceived(object sender, SerialDataReceivedEventArgs e)
{
    if (port.BytesToRead >= sizeof(UInt32))
    {
        var ba = new byte[sizeof(UInt32)];
        if (ba.Length == port.Read(ba, 0, ba.Length))
        {
            UInt32 cmd = BitConverter.ToUInt32(ba, 0);
            switch (cmd)
            {
                case 4: // FanGetResponseMessage
                    { 
                        FanGetResponseMessage msg = default;
                        var o = port.ReadStruct(typeof(FanGetResponseMessage));
                        if (o != null && o is FanGetResponseMessage)
                        {
                            msg = (FanGetResponseMessage)o;
                            response = msg;
                            int rpm = (int)Math.Round((msg.Rpm / msg.MaxRpm) * 100);
                            if(rpm>100)
                            {
                                rpm = 100;
                            }
                            int pwm = (int)Math.Round((msg.Pwm / 65535.0) * 100);
                            Invoke(new Action(() =>
                            {
                                try { RpmBar.Value = rpm; RpmBar.Refresh(); } 
                                catch(ObjectDisposedException) { }
                            }));
                            Invoke(new Action(() =>
                            {
                                try { PwmBar.Value = pwm; PwmBar.Refresh(); } 
                                catch(ObjectDisposedException) { }
                            }));
                            Invoke(new Action(() =>
                            {
                                try
                                {
                                    if ((RpmRadio.Checked == false && msg.Mode == 0) &&
                                        (PwmRadio.Checked == false && msg.Mode == 1))
                                    {
                                        if (msg.Mode == 0)
                                        {
                                            RpmRadio.Checked = true;
                                        }
                                        else
                                        {
                                            PwmRadio.Checked = true;
                                        }
                                    }
                                }
                                catch (ObjectDisposedException) { }
                            }));
                        }
                    }
                    break;
                default:
                    break;
            }
        }
        port.ReadExisting();
    }            
}

First, we see if there's enough data to at least read a command. If so we read it, and then switch on what it is. We only recognize the id for the FanGetMessageResponse structure, so if it's that, then we read it from the serial port using the extension method ReadStruct() from SerialExtensions.cs.

If that was successful, we then compute RPM and PWM values for the progress bars. When we set them, we have to do so from the UI thread, so we use the current synchronization context and Invoke() an Action delegate to be executed on the UI thread.

The radio part is a bit weird but basically we're determining if we need to change the mode, and setting the radios accordingly.

Note that we catch ObjectDisposedException because when the form is closing, this routine could still be firing, raising exceptions we don't want to crash on. We can safely ignore them in this case, so we do.

Finally, we read anything remaining so bad data doesn't stop up the works.

The "connected" checkbox code is simple:

C#
private void ConnectedCheckBox_CheckedChanged(object sender, EventArgs e)
{
    FetchTimer.Enabled = ConnectedCheckBox.Checked;
    RpmRadio.Enabled = ConnectedCheckBox.Checked;
    PwmRadio.Enabled = ConnectedCheckBox.Checked;
    SetTrackBar.Enabled = ConnectedCheckBox.Checked;
    if(!ConnectedCheckBox.Checked)
    {
        if (port != null)
        {
            try
            {
                if (port.IsOpen)
                {
                    port.Close();
                }
            }
            catch { }
            port = null;
        }
    }
}

We enable or disable the controls and the timer based on the state of the check box, and we also close the serial connection if the checkbox is unchecked.

The combo box change code is even simpler:

C#
private void PortCombo_SelectedIndexChanged(object sender, EventArgs e)
{
    if(port!=null && port.IsOpen)
    {
        port.Close();
    }
    port = null;
}

All we're doing here is killing the serial connection if it exists. We'll create it next time it's actually used.

The track bar scroll event is a bit more interesting:

C#
private void SetTrackBar_Scroll(object sender, EventArgs e)
{        
    if (port == null)
    {
        port = new SerialPort(PortCombo.Text, 115200);
        port.DataReceived += Port_DataReceived;
    }
    if (!port.IsOpen)
    {
        port.Open();
    }

    if (RpmRadio.Checked)
    {
        FanSetRpmMessage msg;
        msg.Value = (float)( (SetTrackBar.Value /100.0)*response.MaxRpm);
        var ba = BitConverter.GetBytes((UInt32)1);
        port.Write(ba, 0, ba.Length);
        port.WriteStruct(msg);
    } else
    {
        FanSetPwmMessage msg;
        msg.Value = (UInt16)((SetTrackBar.Value / 100.0) * 65535);
        var ba = BitConverter.GetBytes((UInt32)2);
        port.Write(ba, 0, ba.Length);
        port.WriteStruct(msg);
    }            
}

Here, we create and open the serial port if necessary

Then depending on the mode, we construct either a FanSetRpmMessage or a FanSetPwmMessage and set the value. Then, we convert the appropriate command id to bytes and send it, followed by the struct.

The last two functions are related and simple and so, we'll cover them together:

C#
private void RpmRadio_CheckedChanged(object sender, EventArgs e)
{
    if(RpmRadio.Checked)
    {
        SetTrackBar.Value = (int)(response.Rpm / response.MaxRpm * 100);
    }
}

private void PwmRadio_CheckedChanged(object sender, EventArgs e)
{
    if (PwmRadio.Checked)
    {
        SetTrackBar.Value = (int)(response.Pwm / 65535.0 * 100);
    }
}

Basically, each one recomputes the track bar value if it's selected because the RPM and PWM positions don't necessarily follow each other exactly.

Interface.cs

Now we'll cover Interface.cs, which is the C# rendition of interface.hpp:

C#
using System;
using System.Runtime.InteropServices;
[StructLayout(LayoutKind.Sequential, Pack = 4)]
struct FanSetRpmMessage
{
    public float Value;
}
[StructLayout(LayoutKind.Sequential, Pack = 4)]
struct FanSetPwmMessage
{
    public UInt16 Value;
}

[StructLayout(LayoutKind.Sequential, Pack = 4)]
struct FanGetMessage
{
    public UInt32 Dummy;
}
[StructLayout(LayoutKind.Sequential, Pack = 4)]
struct FanGetResponseMessage
{
    public float MaxRpm;
    public float TargetRpm;
    public float Rpm;
    public UInt16 Pwm;
    public byte Mode;
}

Most of it is pretty easy to understand, but the curious bit is the StructLayout attribute declared over each struct. We use P/Invoke style marshalling to turn these structures to and from byte arrays.

Astute readers might wonder why I didn't use binary serialization since that's what it was designed for. The short answer is I'm lazy, and marshalling was just easier, and it's pretty simple to use in this case, plus it offers the flexibility of declaring fields as various unmanaged types, which makes it easier to represent them on the firmware end in corner cases.

Note the Pack = 4 declaration which gives us the same packing as the ESP32. This is very important or they won't be able to understand each other.

SerialExtensions.cs

Now we'll cover actually turning those structs to and from bytes and transmitting or receiving them over serial in SerialExtensions.cs:

C#
using System;
using System.IO.Ports;
using System.Net;
using System.Runtime.InteropServices;
using System.Reflection;
using System.Text;

internal static class SerialExtensions
{
    public static void WriteStruct(this SerialPort _this, object value)
    {
        var size = Marshal.SizeOf(value.GetType());
        var ptr = Marshal.AllocHGlobal(size);
        Marshal.StructureToPtr(value, ptr, false);
        var ba = new byte[size];
        Marshal.Copy(ptr, ba, 0, size);
        Marshal.FreeHGlobal(ptr);
        _this.Write(ba, 0, ba.Length);
    }

    public static object ReadStruct(this SerialPort _this, Type type)
    {
        var bytes = new byte[Marshal.SizeOf(type)];
        if (bytes.Length != _this.Read(bytes, 0, bytes.Length))
        {
            return null;
        }
        IntPtr ptr = Marshal.UnsafeAddrOfPinnedArrayElement(bytes, 0);
        return Marshal.PtrToStructure(ptr, type);
    }
}

This is a little bit of magic. Basically, what we're doing is cajoling the marshalling subsystem into producing a byte array out of a structure in the first function, and in the second function we're reading the stream into a byte array and then convincing the marshaller into turning it into a structure.

Points of Interest

This serial communication technique using marshalling is easy enough to adapt to your own projects, and is one of the key reasons I shared this article. It's especially useful with ESP32 devkits since they tend to have serial UART to USB bridges on them.

History

  • 10th January, 2023 - Initial submission

License

This article, along with any associated source code and files, is licensed under The MIT License