65.9K
CodeProject 正在变化。 阅读更多。
Home

TTGO 风扇第二版

starIconstarIconstarIconstarIconstarIcon

5.00/5 (8投票s)

2023年1月11日

MIT

20分钟阅读

viewsIcon

7561

downloadIcon

97

比之前功能强大得多的风扇控制器,以及一些强大的编程技术

TTGO Fan Controller

引言

上次,我们构建并回顾了一个简单的风扇控制器。这次,我们通过添加更丰富的用户界面和可连接的 PC 应用程序来扩展它。

这比上次要复杂得多,所以我决定将其作为一篇独立的文章。

从标题图可以看出,这里有很多内容。未显示的是小型可连接 PC 工具,它镜像功能并与 TTGO 同步。

我们将探索一些很酷的技术,例如 alpha 混合、双缓冲以及一种在串行通信中序列化结构体的方法,这样您就可以在 C# 和 C++ 中读写相同的结构体,并且线缆格式是相同的。

这次我们有很多东西要处理。然而,接线和先决条件几乎与以前相同。您需要 Visual Studio 才能编译 PC 端。

必备组件

  • 您需要一个 12V PWM 驱动的 4 针风扇 - 带转速反馈的那种,并且要注意便宜的假转速风扇,它们总是报告相同的 RPM。
  • 您可能需要一个电平转换器。理论上,您可能不需要一个,但我建议使用它,并且不要让电路超出其工作规格。
  • 您需要一个 TTGO Display T1。
  • 您需要一个 12V >= 0.2A 的电源。
  • 拥有一些双头公头杜邦连接器(两端都长)有助于为风扇和 12V 电源鳄鱼夹创建连接点,尽管您也可以用实心铜线勉强完成。
  • 您需要一个可以在 Arduino 上找到的那种 2 线正交编码器。
  • 您需要安装了 PlatformIO 扩展的 VS Code。
  • 您需要一些耐心来遵循 *wiring.txt* 并将所有东西接好。要小心操作,因为您正在处理 12V,所以如果您将其接到风扇以外的任何东西上,都会烧坏设备。我分享接地时会特别紧张,但在这个电路上是可以的。
  • 您需要安装了 .NET Framework 和 C# 的 Visual Studio。

如何使用这个项目

使用起来很简单。在 TTGO 上,您转动编码器来改变 RPM 或 PWM,或者按下任一按钮来改变模式,从设置 PWM 或 RPM。如果处于 RPM 模式,风扇将自动持续调整以尝试达到目标 RPM。请记住,在风扇的有效范围以下,自适应算法可能无法适应。

在 PC 端,如果您运行应用程序并选择 TTGO 的 COM 端口,它将开始在进度条上显示 RPM 和 PWM 值,您可以使用滑块来改变 PWM 或 RPM。单选按钮改变模式,类似于 TTGO 上的按钮。TTGO 将与 PC 应用同步,但由于 WinForms API 的限制,应用中的滑块将无法与 TTGO 同步,这会使 UI 过于复杂,超出了我想要在此展示的范围。

理解这个项目

我们将分段处理,因为有很多移动部件。

TTGO

TTGO 使用我的物联网生态系统来完成所有工作。它使用了我的风扇控制器、我的编码器库、我的按钮库、我的 TTGO 库 - 除了核心 Arduino 框架之外,本项目中的所有依赖项都属于我的生态系统。

这是目的之一 - 让您(读者)熟悉它以及它的功能。

它使用了我制作的 TTGO 库来引入我的图形库、显示器的设备驱动程序以及 TTGO 上的两个按钮。

它还使用了我的编码器库来处理旋钮。

它使用了我的风扇控制器库,该库还包括一个自适应算法,用于即使在环境条件发生变化时也能瞄准特定的 RPM。

这个项目的一个重要部分是它作为 PC 应用接口的串行通信。我们在 C# 端使用了一个技巧,通过 marshalling 创建了一个协议,允许您在 C++ 中重新构建相同的结构体。每个结构体前面都有一个命令标识符,指示其后面的结构体。

基本上,当它启动时,除非您更改代码,否则它将默认检测风扇 RPM,这是在初始化期间完成的。

我们有显示一些居中文本的例程,以及一个显示整个状态屏幕的例程。我们从不直接绘制到显示器。相反,我们绘制到一个位图,然后将其发送到显示器以消除闪烁。这被称为双缓冲。

除此之外,我们连续监测风扇 RPM 和旋钮位置,并每十分之一秒更新一次屏幕。我们还监测串行端口以接收传入的命令和结构体。在两种情况下,我们设置风扇的值,在另一种情况下,我们发送一个包含所有相关风扇和 UI 信息的响应消息。

我们将在下一节代码中介绍细节。

PC 端

PC WinForms 包含一个用于设置 COM 端口的组合框、一个用于连接的复选框、两个用于报告 RPM 和 PWM 的进度条、几个用于更改模式的单选按钮以及一个用于根据模式设置 RPM 或 PWM 的滑块。

同时,有一个定时器每十分之一秒通过串行发送一个请求以检索风扇信息。在接收到事件时,它获取该信息并更新 UI。此代码使用我的串行扩展代码来通过串行连接 marshalling 结构体。

每次滑块改变时,都会构造一个新消息并发送到 TTGO,根据所选模式设置风扇的 RPM 或 PWM。

编写这堆乱七八糟的代码

我们有很多内容要涵盖,其中一些在上一篇文章中已经介绍过,但我们将再次介绍所有内容,以确保不中断连贯性。

Arduino TTGO 固件

这是设置一切的 ini 文件。大部分只是样板代码,除了引入依赖项并将编译器版本更新到 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

大部分内容在 *main.cpp* 中,所以我们将继续在那里。

main.cpp

首先是一些样板代码

// 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;

我们有一些用于引脚常量的定义,以及一个用于风扇最大 RPM 的定义,该定义为 NAN(自动检测),除非您想将其固定为某个值。

之后,我们包含支持硬件和定义串行协议的接口文件的内容。

请注意,我们还包含了一个字体文件,然后声明了一个指向 Telegramaopen_font 引用。此对象来自其正上方的头文件,因此我们可以通过更改这两行来轻松地将字体更改为不同的字体。

现在我们声明编码器和风扇控制器

// 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);

注意 fan_controller 构造函数中的扁平 lambda。这是用于 PWM 回调的,它使用 ESP32 的内部 PWM 设施并将 8 位值写入 PWM 生成器。不同的平台将使用不同的方法,或者可以使用它来调用外部 PWM 生成器硬件。

现在我们设置帧缓冲区。它被用作一个中间的绘图目标/画布,我们在此上绘图,然后一次性将其发送到显示器。这可以减少闪烁。绘制到中间位图然后将其发送到显示器的技术称为 *双缓冲*。

// 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}
    )
];

具体来说,我们声明了一个用于帧缓冲区的位图类型,然后声明一个数组,根据 LCD 显示屏的大小和位图的像素类型(在本例中与显示屏相同)计算其大小。这一切都是 htcw_gfx 的功能。该库的文档 在此

现在一些临时字符串占位符

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

这些被声明为全局变量,因为堆栈空间很宝贵,最好不要使用比必需的更多的空间。我经常采用一种技术,即预先声明我否则必须在堆栈上声明的全局变量。这些稍后与 snprintf() 一起使用。

现在一些更多的全局变量

// 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;

首先,输入 mode 是 RPM 或 PWM,通过 TTGO 按钮切换。

target_rpm 是我们希望维持的 RPM,如果未使用自适应 RPM 目标,则为 NAN。

redraw 变量指示是否需要重绘屏幕。

现在我们有一些用于在循环中跟踪事物的东西,主要是。

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

“旧”值跟踪之前的 RPM 和之前的旋钮位置,以便我们知道是否需要更新。ts 变量是一个时间戳,我们用它在 loop() 中实现一个简单的计时器。

现在我们开始进入一些好东西。让我们从绘制居中文本开始

// 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());
}

我们首先等待 LCD 可能挂起的任何 DMA 操作完成。这是因为我们的 DMA 缓冲区本身就是帧缓冲区,而我们即将对其进行绘制。如果在 DMA 传输正在进行时进行绘制,至少会导致显示损坏,所以我们希望避免这种情况。

这里我们实际上不需要使用 DMA,但它确实使事情更有效率,而且我想展示使用 htcw_gfx 有多容易。基本上,如果您需要等待之前执行的任何操作,只需额外调用一次。启动后台 DMA 传输只需调用 draw::bitmap_async()draw::batch_async()

总之,现在我们用 frame_buffer_t 具体类型的 bitmap<> 模板实例化包装了 frame_buffer_data[]。简单来说,我们用一个位图对象包装了我们的帧缓冲区数组。这在全局变量部分没有完成,因为它实际上是不必要的。位图对象本身非常轻量级,仅用作像素数据数组的“视图”。即使在关键代码路径中,创建和丢弃位图对象也是完全可行的。像素数据内存本身是沉重的,而不是包装它的位图。

接下来,我们通过填充一个紫色矩形来清除位图。htcw_gfx 在创建时不会清除位图,因为有时不需要,例如当整个位图都会被重绘时。为了防止垃圾数据,您必须自己清除它,通常使用 draw::filled_rectangle(),但您也可以使用位图本身的 clear()fill() 方法。由于之前的绘制,我们无论如何都必须这样做。

现在我们开始填充一个 open_text_info 结构体,其中包含我们的字体和文本信息。字体的 scale() 方法接受一个像素行高,并将其转换为字体巨大的原生大小的分数缩放。您可以使用它以所需的尺寸渲染字体。

在居中文本之前,我们必须知道它在像素中会占据多大的区域。我们可以使用字体的 measure_text() 方法来做到这一点,我们在那里进行。第一个参数是我们可用的总布局区域。我们只使用最大尺寸。第二个参数是布局区域中我们开始绘制的偏移量。我们只使用 (0,0)。之后是 text 本身和 scale。当我们得到大小后,我们使用 bounds() 将其转换为矩形,这是一个常用方法,用于从各种 htcw_gfx 对象(如路径、大小和绘图目标)获取边界矩形。

一旦我们有了矩形,我们就会根据帧缓冲区的边界将其居中。这意味着,而不是返回一个居中的矩形副本(像我们使用 center() 时那样),center_inplace() 会修改源矩形本身。任何 _inplace() 方法都会修改源对象本身。

现在我们绘制文本本身,传入我们的目标和目标矩形、打开文本结构以及前景色和背景色。这里我们不需要指定背景色,因为我们没有绘制背景,但如果我们在打开文本结构中禁用了透明背景,我们就需要一个,所以我把它包括进来了。

最后,我们使用 draw::bitmap_async() 启动 frame_bufferlcd 的异步传输。此方法几乎立即完成,而传输在后台继续。这意味着您的代码可以在传输进行时继续运行,使用 DMA 的魔力。htcw_gfx 在支持的平台和驱动程序上是 DMA 感知的。在不支持 DMA 的设置中,异步方法是同步的,但这里我们有 DMA。

现在我们来绘制状态屏幕。这与上面非常相似,只是文本更多,并且增加了一个图形指示器条,我们将对其进行探讨。总的来说,这很长,但这里的大部分代码我们已经在上面介绍了理论。

// 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());
}

这里唯一新增的是 PWM/RPM 指示条,所以我们来谈谈它。

它实际上由三个填充的矩形组成。第一个是背景,是深灰色,所以我们绘制它。

接下来,我们绘制一个宽度基于风扇 pwm_duty() 值的黄色条。

现在变得有趣了。我们创建一个带有 alpha 通道的 32 位 RGB 像素(rgba_pixel<32>)并将其设置为橙红色。

然后我们获取 alpha 通道并将其设置为 127,这是最大值的 50%。这给了它 50% 的透明度,意味着下面颜色的 50% 会透过来。这样,当我们将其绘制在之前的条形图上时,您仍然可以看到所有内容。使用该颜色,我们绘制最后一个条形图。

现在处理 TTGO 按钮处理

// 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;
}

我们在这里做的是改变模式。按钮支持多击,这样如果您快速按下它,它会以点击次数触发一次事件。我们不关心这个,我们只想要单次点击。诀窍是将所有内容简化为奇偶数,并根据此设置模式。这会将所有内容简化为一个简单的切换。

如果模式是 RPM,我们必须重置旋钮位置,以便它反映当前的 RPM。这是因为 RPM 会随着风扇的运行而变化,并且在任何情况下都不等于 PWM 值。

我们还将它限制在 max_rpm() 范围内,因为 rpm() 可能大于该值,特别是在您在构造函数中自己设置最大 RPM 的情况下。

最后,我们将旧值设置为强制所有内容重新计算,并将 redraw 标志设置为更新屏幕。

进入 setup(),一切从这里开始

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);
}

我认为这不需要比注释提供的更多解释。这里唯一的意外是我们将按钮点击处理程序设置为相同的处理程序,因为它们都做同样的事情。

loop() 是发生魔力的地方,而且有点复杂,所以我们将分几个部分来介绍它。

串行通信

这很有趣

// 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();
    }
}

首先,我们确保流中至少有一个 32 位整数。这是我们的命令 ID,我们用它来确定接下来是什么。所以,如果它正在等待,我们就会把它从流中读出来,然后 switch。在 *interface.hpp* 中,我们拥有所有这些结构体,用于我们在串行消息中发送和/或接收的消息。它们不包含命令 ID,但它们有一个常量 command,表示该结构体的命令 ID 应该是多少。我们在 case 行中使用它。请注意,我们用 {} 将每个完整的 case 块括起来。这是因为我们在每个 case 下声明了变量,而我们只希望它们在该 case 的范围内。

我们声明相应的结构体以匹配相应的命令,然后将其转换为一系列字节从流中读出。然后我们可以根据需要使用它。

在前面两个 case 中,我们分别将其用于设置风扇的 RPM 或 PWM。

最后一个非默认 case 更复杂。当它收到一条消息时,它会发送一条响应消息,其中包含相关的 UI 和风扇信息,PC 应用可以使用这些信息来同步自己。它的工作方式有点像 HTTP 请求/响应周期,只是二进制的,并且通过串行。

default case 中,我们消耗任何我们不识别的内容,以便为我们识别的内容腾出空间。这可以防止在遇到坏数据时卡住。

如果我们没有在流中得到一个正确的命令,我们也会这样做,正如 else 之后所示。

UI 代码
// 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();

在这里,我们让 fan update(),这包括重新计算 RPM,并在适当时调整 PWM 以尝试达到目标 RPM。

之后,我们强制 knobposition() 限制在零到一百之间(包括边界)。

接下来,我们检查 RPM 是否已更改。如果是,我们指示需要 redraw 屏幕,但我们使用 ts 来确保我们每秒不会执行超过十次。

如果确实需要 redraw,我们就使用 snprintf() 和我们之前的全局临时字符串来格式化 RPM 和 PWM 数据的显示字符串。然后我们绘制,并重置 redraw 标志。

最后,如果 knob 位置已更改,我们就根据 mode 更改风扇的 rpm()pwm_duty()。然后我们强制重绘。

当我们在不使用调光器时,其余的是样板代码。它会保持按钮回调触发和屏幕背光常亮。

interface.hpp

此文件本质上定义了我们的串行协议,即一系列由命令 ID 前缀的结构体。它确实不能再简单了,或者至少这就是想法。

#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;
};

请注意,每个结构体都有一个不同的常量命令 ID。

这里唯一的怪异之处是 dummy,它是因为 C 和 C++ 处理“零长度”/空结构体的奇怪之处而存在的。它们不是零长度 - sizeof() 将返回一,至少在 ESP32 上使用 Platform IO 工具链时是这样。我没有深入研究这个怪癖的普遍性,但它意味着如果您不想对您的 struct 读取和写入代码进行特殊处理,就必须包含一个字段。dummy 字段就起到了这个作用。

还应注意,这些结构体在 ESP32 平台上使用默认的四字节打包。这一点很重要,因为我们必须在 PC 端匹配它,才能使它们能够相互通信。这比看起来要容易。有一个技巧,我们将在稍后进行探索。

另请注意,我们可以通过一系列搜索和替换来创建此文件的 C# 等效文件,我强烈推荐这种方法来减少错误,但同样,我们将在稍后讨论。

WinForms .NET PC 应用程序

在看的时候能看到 UI 可能会更容易一些

Fan controller PC application

我们将从主窗体开始,如上图所示

Main.cs

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

这基本上启动了我们的窗体,并填充了 COM 端口组合框。窗体的成员持有活动的串行连接和最新的风扇信息响应。

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

    PortCombo.SelectedIndex = 0;
}

这会用 COM 端口填充我们的组合框,并将组合框设置为第一个条目。

我们有一个每十分之一秒运行一次的计时器,它在需要时创建和/或打开 COM 端口,并在需要时附加数据接收事件。然后它写入请求风扇信息的命令(三),并传输在 *Interface.cs* 中定义的 FanGetMessage 结构体。它使用 WriteStruct(),这是在 *SerialExtensions.cs* 中定义的扩展方法。

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);
}

接下来,我们处理 DataReceived event,我们用它来检索风扇信息并更新 UI。

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();
    }            
}

首先,我们检查是否有足够的数据至少读取一个命令。如果有,我们就读取它,然后对其进行 switch。我们只识别 FanGetMessageResponse 结构体的 ID,所以如果是它,我们就使用 *SerialExtensions.cs* 中的扩展方法 ReadStruct() 从串行 port 中读取它。

如果成功,我们然后计算进度条的 RPM 和 PWM 值。设置它们时,我们必须从 UI 线程进行,所以我们使用当前同步上下文并 Invoke() 一个 Action 委托在 UI 线程上执行。

单选按钮部分有点奇怪,但基本上我们是在确定是否需要更改模式,并相应地设置单选按钮。

请注意,我们 catch ObjectDisposedException,因为当窗体关闭时,此例程仍可能触发,从而引发我们不想崩溃的异常。在这种情况下,我们可以安全地忽略它们,所以我们这样做。

最后,我们读取任何剩余的数据,以免坏数据阻塞进程。

“已连接”复选框代码很简单

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;
        }
    }
}

我们根据复选框的状态启用或禁用控件和计时器,并且在复选框未选中时关闭串行连接。

组合框更改代码更简单

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

我们所做的就是终止串行连接(如果存在)。下次实际使用时我们会创建它。

滑块滚动事件更有趣

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);
    }            
}

这里,我们在必要时创建并打开串行 port

然后根据 mode,我们构造一个 FanSetRpmMessageFanSetPwmMessage 并设置 value。然后,我们将相应的命令 ID 转换为字节并发送它,然后是 struct

最后两个函数是相关的并且很简单,所以我们将它们一起介绍。

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);
    }
}

基本上,每个函数都会重新计算滑块值(如果被选中),因为 RPM 和 PWM 的位置不一定完全匹配。

Interface.cs

现在我们将介绍 *Interface.cs*,这是 *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;
}

大部分内容都比较容易理解,但奇怪之处在于每个 struct 上声明的 StructLayout 属性。我们使用 P/Invoke 样式 marshalling 将这些结构体转换为字节数组,然后再转换回来。

敏锐的读者可能会想,为什么我没有使用二进制序列化,因为它就是为此设计的。简短的回答是,我很懒,而且 marshalling 更容易,在这个例子中也相对简单,而且它提供了将字段声明为各种非托管类型的灵活性,这使得在极端情况下更容易在固件端表示它们。

请注意 Pack = 4 声明,它给了我们与 ESP32 相同的打包。这一点非常重要,否则它们将无法相互理解。

SerialExtensions.cs

现在我们将介绍如何在 *SerialExtensions.cs* 中将这些结构体实际转换为字节,以及从字节转换回来,并通过串行进行传输或接收。

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);
    }
}

这是有点魔法。基本上,在第一个函数中,我们将 marshalling 子系统诱导生成结构体的字节数组;在第二个函数中,我们将流读入字节数组,然后说服 marshaller 将其转换为结构体。

关注点

这种使用 marshalling 的串行通信技术足够容易适应您自己的项目,这也是我分享这篇文章的关键原因之一。对于 ESP32 开发板来说尤其有用,因为它们通常自带串行 UART 到 USB 桥接器。

历史

  • 2023年1月10日 - 首次提交
TTGO 风扇 第二版 - CodeProject - 代码之家
© . All rights reserved.