TTGO 风扇第二版





5.00/5 (8投票s)
比之前功能强大得多的风扇控制器,以及一些强大的编程技术
引言
上次,我们构建并回顾了一个简单的风扇控制器。这次,我们通过添加更丰富的用户界面和可连接的 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(自动检测),除非您想将其固定为某个值。
之后,我们包含支持硬件和定义串行协议的接口文件的内容。
请注意,我们还包含了一个字体文件,然后声明了一个指向 Telegrama
的 open_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_buffer
到 lcd
的异步传输。此方法几乎立即完成,而传输在后台继续。这意味着您的代码可以在传输进行时继续运行,使用 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。
之后,我们强制 knob
将 position()
限制在零到一百之间(包括边界)。
接下来,我们检查 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 可能会更容易一些
我们将从主窗体开始,如上图所示
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
,我们构造一个 FanSetRpmMessage
或 FanSetPwmMessage
并设置 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日 - 首次提交