Funpack3-5
——板卡二 任务一:基于Teensy 4.1的远程灯控
一、项目介绍
本项目依托于Funpack3-5活动,实现一基于TCP/IP协议通信的远程灯控装置。本项目采用上位机与受控段配合的运行模式。其中上位机采用QT开发,实现了全平台上位机。受控端为Teensy 4.1,使用Arduino IDE基于Arduino框架开发。
项目内容:
1、开发一具有TCP/IP通信功能的全平台上位机。
2、开发一套具有TCP/IP通信功能的板卡控制程序。
应用场景
本项目为一远程灯控系统的简易demo,通过与上位机的配合,可以实现远程灯光的远程控制。在现实生活中,往小,可以应用到智能台灯;往大,可以应用到LED照明系统的控制。总之,本项目为基于TCP/IP协议通信的远程灯具提供了一种方案及实现。
二、总体架构
本项目建立在局域网通信的基础上,使用TCP/IP协议,以Teensy 4.1为服务器,使用Qt开发Windows、Linux、Android客户端,是一套上位机-受控端配合的嵌入式系统方案。
三、Teensy 4.1软件
Teensy 4.1支持Arduino框架或者CircuitPython开发,支持Arduino IDE、PltaformIO及VS studio等多种开发环境。相互之间并无优劣之分,出于作者习惯,使用ArduinoIDE开发。
3.1 环境安装
笔者采用ArduinoIDE 2.3.1版本,通过在开发板管理器添加链接即可支持。
https://www.pjrc.com/teensy/package_teensy_index.json
笔者安装时多次出现网络问题,可以进入链接的网页,下载无法安装的包,放入ArduinoIDE报错的缓存文件夹内即可。
3.2 软件架构
Teensy 4.1程序流程图如图所示,系统上电后,首先初始化串口,之后是LED,最后是网络通信相关。然后,启动服务器。至此,系统的初始化部分完成,进入主循环。主循环会循环监听是否有新的客户端接入,接收客户端的指令,执行相应的动作,清理僵尸连接。
3.3 软件实现
3.3.1 系统初始化函数
void setup() {
pinMode(LED_BUILTIN, OUTPUT);
Serial.begin(115200);
while (!Serial && millis() < 4000) {
// Wait for Serial
}
printf("Starting...\r\n");
// Unlike the Arduino API (which you can still use), QNEthernet uses
// the Teensy's internal MAC address by default, so we can retrieve
// it here
uint8_t mac[6];
Ethernet.macAddress(mac); // This is informative; it retrieves, not sets
printf("MAC = %02x:%02x:%02x:%02x:%02x:%02x\r\n",
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
// Add listeners
// It's important to add these before doing anything with Ethernet
// so no events are missed.
// Listen for link changes
Ethernet.onLinkState([](bool state) {
printf("[Ethernet] Link %s\r\n", state ? "ON" : "OFF");
});
// Listen for address changes
Ethernet.onAddressChanged([]() {
IPAddress ip = Ethernet.localIP();
bool hasIP = (ip != INADDR_NONE);
if (hasIP) {
printf("[Ethernet] Address changed:\r\n");
printf(" Local IP = %u.%u.%u.%u\r\n", ip[0], ip[1], ip[2], ip[3]);
ip = Ethernet.subnetMask();
printf(" Subnet = %u.%u.%u.%u\r\n", ip[0], ip[1], ip[2], ip[3]);
ip = Ethernet.gatewayIP();
printf(" Gateway = %u.%u.%u.%u\r\n", ip[0], ip[1], ip[2], ip[3]);
ip = Ethernet.dnsServerIP();
if (ip != INADDR_NONE) { // May happen with static IP
printf(" DNS = %u.%u.%u.%u\r\n", ip[0], ip[1], ip[2], ip[3]);
}
} else {
printf("[Ethernet] Address changed: No IP address\r\n");
}
});
if (initEthernet()) {
// Start the server
printf("Starting server on port %u...", kServerPort);
server.begin();
printf("%s\r\n", (server) ? "Done." : "FAILED!");
}
}
这部分代码是系统初始化部分,刨除打印调试信息部分,主要是串口、LED及网络的初始化,并启动了TCP服务器。
3.3.2 以太网初始化
bool initEthernet() {
// DHCP
if (staticIP == INADDR_NONE) {
printf("Starting Ethernet with DHCP...\r\n");
if (!Ethernet.begin()) {
printf("Failed to start Ethernet\r\n");
return false;
}
// We can choose not to wait and rely on the listener to tell us
// when an address has been assigned
if (kDHCPTimeout > 0) {
printf("Waiting for IP address...\r\n");
if (!Ethernet.waitForLocalIP(kDHCPTimeout)) {
printf("No IP address yet\r\n");
// We may still get an address later, after the timeout,
// so continue instead of returning
}
}
} else {
// Static IP
printf("Starting Ethernet with static IP...\r\n");
if (!Ethernet.begin(staticIP, subnetMask, gateway)) {
printf("Failed to start Ethernet\r\n");
return false;
}
// When setting a static IP, the address is changed immediately,
// but the link may not be up; optionally wait for the link here
if (kLinkTimeout > 0) {
printf("Waiting for link...\r\n");
if (!Ethernet.waitForLink(kLinkTimeout)) {
printf("No link yet\r\n");
// We may still see a link later, after the timeout, so
// continue instead of returning
}
}
}
return true;
}
以上这部分主要是初始化系统的以太网,首先根据是否是静态IP,决定等待DHCP分配IP还是静态IP连接路由器,启动连接,超时未连接或连接成功都会打印调试信息。
3.3.3 接收数据处理程序
void processClientData(ClientState &state) {
// Loop over available data until an empty line or no more data
// Note that if emptyLine starts as false then this will ignore any
// initial blank line.
while (true) {
int avail = state.client.available();
if (avail <= 0) {
return;
}
state.lastRead = millis();
int c = state.client.read();
state.client.flush();
printf("%c", c);
if (c == '1')
digitalWrite(LED_BUILTIN, HIGH);
else
digitalWrite(LED_BUILTIN, LOW);
// printf("1\n");
if (c == '\n') {
if (state.emptyLine) {
break;
}
// Start a new empty line
state.emptyLine = true;
} else if (c != '\r') {
// Ignore carriage returns because CRLF is a likely pattern in
// an HTTP request
state.emptyLine = false;
}
}
IPAddress ip = state.client.remoteIP();
printf("Sending to client: %u.%u.%u.%u\r\n", ip[0], ip[1], ip[2], ip[3]);
state.client.writeFully("HTTP/1.1 200 OK\r\n"
"Connection: close\r\n"
"Content-Type: text/plain\r\n"
"\r\n"
"Hello, Client!\r\n");
state.client.flush();
// Half close the connection, per
// [Tear-down](https://datatracker.ietf.org/doc/html/rfc7230#section-6.6)
state.client.closeOutput();
state.closedTime = millis();
state.outputClosed = true;
}
这部分是接收数据处理程序,根据接收字符解析控制命令,然后执行。
四、上位机软件
对于跨平台的网络通信客户端来说,有多种选择,诸如QT、Electron、Tarui及Flutter等等,他们有着不同的开发特点与优势。本项目采用QT作为上位机框架,其安装包体积较小,占用内存较少,更能适应资源较少的设备,因而拥有更强的适用范围。
4.1 开发环境
即使限定QT开发,也有多种开发环境。虽然QTCreator存在诸多缺点,本项目仍旧使用QTCreator开发,因其作为QT原生IDE,拥有其他IDE所不具备的开箱即用的方便。
对于本项目,已经验证了基于QT 6.8 版本,基于qmake,使用MSVC2019编译。
安装QT时注意安装网络通信相关的模块。
4.2 软件架构
QT主要利用信号与槽这一机制完成操作。以点灯为例。如果连接成功,发送点灯信号后立刻关闭连接,会导致点灯信号不能被稳定接收,因此设计如下流程。当“点灯”按键按下,按键对应的槽函数启动,发起网络连接。连接成功信号发出后,弹出连接成功指示框,程序运行槽函数,向受控端发送点灯信号,启动定时器。定时器超时后,槽函数终止网络连接。本次操作结束。
4.3 软件实现
QT框架下程序架构与常规C/C++并不相同,存在主函数与多个窗口函数等,此处不再赘述,请读者自行了解。
4.3.1 主函数实现
int main(int argc, char *argv[])
{
QApplication a(argc, argv);
a.setStyle(QStyleFactory::create("fusion"));
MainWindow w;
w.show();
return a.exec();
}
界面实现较为简单,首先生成一个应用,设置显示风格,显示。主程序就结束了
4.3.2 主窗口初始化程序
MainWindow::MainWindow(QWidget *parent)
: QMainWindow(parent)
, ui(new Ui::MainWindow)
{
ui->setupUi(this);
onSocket=new QTcpSocket(this);
offSocket=new QTcpSocket(this);
tim1=new QTimer(this);
tim1->stop();
tim1->setTimerType(Qt::PreciseTimer);
tim1->setInterval(50);
tim1->setSingleShot(true);
QObject::connect(tim1,&QTimer::timeout,this,[&](){
onSocket->disconnectFromHost();
;
});
tim2=new QTimer(this);
tim2->stop();
tim2->setTimerType(Qt::PreciseTimer);
tim2->setInterval(50);
tim2->setSingleShot(true);
QObject::connect(tim2,&QTimer::timeout,this,[&](){
offSocket->disconnectFromHost();
;
});
QObject::connect(offSocket,&QTcpSocket::connected,this,[&](){
offSocket->write("0");
// mSocket->write("0\n");
tim2->start();
});
QObject::connect(onSocket,&QTcpSocket::connected,this,[&](){
onSocket->write("1");
// mSocket->write("1\n");
tim1->start();
// mSocket->disconnectFromHost();
});
}
主窗口的初始化主要是设置UI界面,以及各定时器、信号与槽的连接等等。首先创建UI控件、生成网络通信变量。然后创建两个定时器,并通过虚函数链接定时器超时信号与处理函数。最后,通过虚函数的方式连接网络连接的信号与处理函数。
4.3.3 主界面按键槽函数
void MainWindow::on_pushButton_clicked()
{
QString ip=ui->lineEdit->text();
quint16 port=ui->spinBox->value();
offSocket->connectToHost(ip,port);
if(!offSocket->waitForConnected(40))
{
QMessageBox::warning(this, QObject::tr("连接受控端"), QObject::tr("连接失败"));
}
else {
QMessageBox::information(this, QObject::tr("连接受控端"), QObject::tr("成功"));
}
}
void MainWindow::on_pushButton_2_clicked()
{
QString ip=ui->lineEdit->text();
quint16 port=ui->spinBox->value();
onSocket->connectToHost(ip,port);
if(!onSocket->waitForConnected(40))
{
QMessageBox::warning(this, QObject::tr("连接受控端"), QObject::tr("连接失败"));
}
else {
QMessageBox::information(this, QObject::tr("连接受控端"), QObject::tr("成功"));
}
}
这部分是主界面两个按键对应的槽函数,分别对应“关灯”和“开灯”。两个函数大体相同,首先读取受控端IP和端口,然后发起连接,等待40ms,弹出连接成功或失败弹窗。
4.3.4 主界面关闭程序
MainWindow::~MainWindow()
{
onSocket->disconnectFromHost();
offSocket->disconnectFromHost();
delete ui;
}
这部分是主界面关闭时执行的程序,主要是为防止过多的连接导致受控端不能正常运行,退出前断开可能存在的连接,然后关闭界面。
五、最终效果
具体效果请参考视频,此处放出上位机截图。
六、展望
本项目在嵌入式代码安全相关有一定缺陷,未考虑与商业化相关的代码加密、固化等操作。如果优化本项目,可以考虑利用Teensy官方设计的安全相关功能,提高产品的保密性能。
由于本项目的网络通信存在未知缺陷,导致TCP连接会不定期断联,因而本项目采取每次操作都先连接服务器,操作完成后再断开的方式,避免TCP断联导致系统操作无反应等恶性问题。但是,终归不够完美。如果能排查到网络通信的未知问题,就可以避免这种运行方式。