分享一篇轻松好玩的内容,用几十块钱的成本为原本不具备显示屏的设备,增加电子显示屏。
写在前面
利用本篇文章的思路,举一反三,不仅可以为 NAS 增加屏幕,也可以为路由器、交换机、小主机等一系列没有显示器,又不想外接显示器或者打开网页浏览的设备进行低成本扩展,快速获取设备运行关键信息。
虽然将电子设备通过 NMAP 或者通过 Exporter 接入监控系统,并配合通知系统来仅在设备出现异常时进行提醒更为高效。但很多时候,如果能够不打开网页就能看到设备的运行状况,总归会让人更安心一些。
前一阵我又入手了两台 Gen10,目前家里已经有三台这样的设备和若干其他的设备在“Headless 运行”,而这些设备都缺少一个抬头就能看到信息的“可视化”的监控方式。
最近在知乎上看到一个问题:“生活中的哪些瞬间让你感受到了「开源精神」还不错?”。我觉得能够折腾有趣的事情,并将过程和做好的小作品分享给更多人,让大家都能参与进来一起折腾,这种感觉就很棒。
所以,就有了这篇文章。本文中的所有代码和内容都已开源,感兴趣的朋友可以自取:soulteary/mini-probe
好了,让我们开始实践吧。
准备材料
除了被监控的设备外,我们需要一块用于显示内容的屏幕(液晶屏),一块用来驱动显示屏的开发板,以及一根连接设备和开发板的数据线:
我使用了一块 Arduino UNO 开发板,实际上使用 NodeMCU、各种派也是一样的,推荐有什么用什么,什么便宜用什么,个人建议可以购买国产改进版试试看(亲测没啥差异);液晶屏我选择的是 IIC 2004 LCD,类似的还有 IIC 1602 LCD,主要差别在于尺寸和可显示的字符数;数据线选择开发板附带的,或者家里闲置的就行。
被监控的设备这边,需要能够运行 Docker,这样我们的使用、迁移、复用和管理都能省很多精力。
至于代码部署,我们使用官方提供的 Arduino IDE,选择好单片机型号,然后将代码复制粘贴到编辑器里,点击“上传”按钮就好了,没有什么复杂的事情。
阶段一:完成单片机和液晶屏幕可控程序设计
第一阶段的事情,我们需要完成单片机和屏幕的程序,并让单片机能够通过指令动态更新屏幕内容。
单片机和液晶屏的 “Hello World”
熟悉单片机的同学可以跳过这部分内容,直接阅读后面的几个阶段内容。还不熟悉单片机的同学可以从下面不到 30 行代码来开始熟悉这个“点灯(屏幕)”程序(代码开源在stage/test-lcd/hello-world.ino):
#include <Wire.h>
#include <LiquidCrystal_I2C.h>
// Initialize LCD at address 0x27, size 20x4
LiquidCrystal_I2C lcd(0x27, 20, 4);
void setup() {
lcd.init(); // Initialize LCD
lcd.backlight(); // Turn on backlight
// Display A Poem
lcd.setCursor(2, 0);
lcd.print("The Road Not Taken");
lcd.setCursor(0, 1);
lcd.print("Two roads diverged");
lcd.setCursor(0, 2);
lcd.print("in a yellow wood,");
lcd.setCursor(0, 3);
lcd.print("- Robert Frost -");
}
void loop() {
// Empty loop
}
在上面的程序中,我们通过 I2C 接口驱动了一块 20x4 的液晶屏,并在屏幕上显示了一首简短的英文诗句。这段基础的嵌入式系统中的“Hello World”,它不仅展示了基本的文本输出能力,也验证了屏幕初始化、光标定位和内容刷新等(硬件开发板和屏幕的)基本功能是否正常工作。
着急的同学先别急,代码函数封装这些以往写程序时,我们会做的“基操”,可以等最终实现的时候再做。过早优化没有必要,因为耐心往下看,我们的程序会大不一样。
另外,这里之所以使用英文,是因为我们选择的这类最基础的显示屏不支持中文字库(使用变通方案展示不够美观),后续如果有相关文章,我会分享其他类型屏幕的折腾方案。
完善信息呈现交互(溢出内容滚动)
因为我们使用的屏幕只有 20x4 的内容展示区域,所以当我们需要呈现的内容过长时,就会出现信息展示不够完整的情况。最基础的交互方案是:采取信息滚动。
为了更好的进行测试验证,我们将短文诗和代码一起更新(代码开源在 stage/test-lcd/overflow-scroll.ino):
#include <Wire.h>
#include <LiquidCrystal_I2C.h>
LiquidCrystal_I2C lcd(0x27, 20, 4);
const char* lines[4] = {
"Stopping by Woods",
"on a Snowy Evening",
"Whose woods these are",
"I think I know."
};
byte scrollPos[4] = { 0, 0, 0, 0 };
const byte lineLength = 20;
const unsigned long scrollDelay = 300;
unsigned long previousMillis = 0;
void setup() {
lcd.init();
lcd.backlight();
for (byte i = 0; i < 4; i++) {
lcd.setCursor(0, i);
lcd.print(lines[i]);
}
}
void loop() {
unsigned long currentMillis = millis();
if (currentMillis - previousMillis >= scrollDelay) {
previousMillis = currentMillis;
for (byte i = 0; i < 4; i++) {
lcd.setCursor(0, i);
byte lineLen = strlen(lines[i]);
if (lineLen > lineLength) {
for (byte j = 0; j < lineLength; j++) {
byte charIndex = (scrollPos[i] + j) % lineLen;
lcd.print(lines[i][charIndex]);
}
scrollPos[i] = (scrollPos[i] + 1) % lineLen;
} else {
lcd.print(lines[i]);
for (byte j = lineLen; j < lineLength; j++) {
lcd.print(" ");
}
}
}
}
}
在上面的程序中,我们实现了一个简单但有效的文本滚动机制,可以处理超出LCD屏幕宽度的内容。具体实现了下面的内容:
- 我们为每一行文本定义了一个滚动位置计数器 scrollPos,用于跟踪当前每行显示的起始位置
- 在主循环中,程序会定期(每300毫秒)检查是否需要更新显示内容
- 对于长度超过屏幕宽度(20字符)的文本行,程序会从当前滚动位置开始,逐个字符地在 LCD 上显示内容
- 通过取模运算
(scrollPos[i] + j) % lineLen
,我们实现了文本的循环滚动效果,使文本能够从头到尾、再从头开始连续滚动 - 滚动位置计数器在每次更新后递增,并通过取模运算确保它始终在有效范围内
- 对于不需要滚动的短文本行(长度小于或等于20字符),程序会直接显示文本并用空格填充剩余位置
这种实现方式的优点是代码结构清晰,资源占用少,并且能够独立控制每一行的滚动行为。当然,这里的滚动是单向的(从右向左)。
基础交互完毕,我们来尝试模拟最终展示信息铺满屏幕时的状况。
进行最初的基础 UI 设计
以我预期的使用方式为例,我会将设备的“运行时间”、“内存使用情况”、“网络使用情况”、“磁盘使用情况”都打印在屏幕上。
所以,我们可以用一些模拟(Mock)信息来对程序进行更新,并适当调整展示相关的参数,让整个交互稍微令人观看的时候舒服些。(代码stage/test-lcd/lcd-info-scroll.ino)
#include <Wire.h>
#include <LiquidCrystal_I2C.h>
LiquidCrystal_I2C lcd(0x27, 20, 4);
const char* headers[4] = {
"UPTIME:",
"RAM:",
"NET(U/D):",
"DISK(R/W):"
};
const char* contents[4] = {
"428h8m0s",
"83.76% (20.1GB/24.0GB)",
"101.4GB / 173.6GB",
"3.1TB / 965.3GB"
};
byte scrollPos[4] = { 0, 0, 0, 0 };
const byte lineLength = 20;
const unsigned long scrollDelay = 500;
unsigned long previousMillis = 0;
void setup() {
lcd.init();
lcd.backlight();
for (byte i = 0; i < 4; i++) {
lcd.setCursor(0, i);
lcd.print(headers[i]);
}
}
void loop() {
unsigned long currentMillis = millis();
if (currentMillis - previousMillis >= scrollDelay) {
previousMillis = currentMillis;
for (byte i = 0; i < 4; i++) {
lcd.setCursor(strlen(headers[i]) + 1, i);
byte contentLen = strlen(contents[i]);
byte availableSpace = lineLength - (strlen(headers[i]) + 1);
if (contentLen > availableSpace) {
for (byte j = 0; j < availableSpace; j++) {
byte charIndex = (scrollPos[i] + j) % contentLen;
lcd.print(contents[i][charIndex]);
}
scrollPos[i] = (scrollPos[i] + 1) % contentLen;
} else {
lcd.print(contents[i]);
for (byte j = contentLen; j < availableSpace; j++) {
lcd.print(" ");
}
}
}
}
}
在上面的程序中,我们实现了一个基础的系统监控UI界面,采用了标题和内容分离的设计模式,并为溢出内容添加了滚动功能。具体实现了下面的内容:
- 将界面划分为标题区和内容区,每行显示一类系统信息,包括运行时间、内存使用、网络流量和磁盘读写
- 标题区采用固定位置显示,内容区根据需要自动滚动
- 在计算每行可用显示空间时,考虑了标题长度的影响,确保内容从标题后方的位置开始显示
- 针对内容区的滚动实现了智能判断:只有当内容长度超过可用空间时才启用滚动功能
- 通过
availableSpace = lineLength - (strlen(headers[i]) + 1)
计算每行实际可用于内容显示的字符数量,留出一个字符的间隔使界面更清晰 - 滚动速度调整为500毫秒,比之前的实现慢一些,更适合阅读系统信息
这种 UI 设计的优点是清晰度相对较高,我们可以一目了然地看到各类系统信息的分类,同时通过滚动技术解决了内容溢出的问题。而对于 RAM
和 NET(U/D)
等较长数据,系统会自动滚动显示完整信息。
并且,这个简单的设计还具有很好的扩展性,只需修改 headers
和 contents
数组中的内容,就能适应不同的监控需求。未来我们可以进一步完善这个界面,例如添加刷新实时数据的功能,或者增加用户交互控制选项。
LCD 屏幕按远程指令更新内容
在前面的文章里,我们能够通过程序来控制单片机在屏幕上呈现内容,并且能够控制程序处理好溢出屏幕外的内容,现在再实现好遵循远程命令更新内容,基本的单片机程序就完成了(代码开源在stage/test-lcd/wait-for-call.ino)
#include <Wire.h>
#include <LiquidCrystal_I2C.h>
LiquidCrystal_I2C lcd(0x27, 20, 4);
const char* headers[4] = {
"UPTIME",
"RAM",
"NET(U/D)",
"DISK(R/W)"
};
String contents[4] = {
"waiting...",
"waiting...",
"waiting...",
"waiting..."
};
byte scrollPos[4] = { 0, 0, 0, 0 };
const byte lineLength = 20;
const unsigned long scrollDelay = 500;
unsigned long previousMillis = 0;
void setup() {
lcd.init();
lcd.backlight();
Serial.begin(115200);
for (byte i = 0; i < 4; i++) {
lcd.setCursor(0, i);
lcd.print(headers[i]);
}
}
void loop() {
unsigned long currentMillis = millis();
if (Serial.available() > 0) {
String received = Serial.readStringUntil('\n');
received.trim(); // 移除首尾空格及换行符
int sepIndex = received.indexOf(':');
if (sepIndex > 0) {
String header = received.substring(0, sepIndex);
String data = received.substring(sepIndex + 1);
header.trim();
data.trim();
for (byte i = 0; i < 4; i++) {
if (header.equalsIgnoreCase(headers[i])) {
contents[i] = data;
scrollPos[i] = 0;
}
}
}
}
if (currentMillis - previousMillis >= scrollDelay) {
previousMillis = currentMillis;
for (byte i = 0; i < 4; i++) {
lcd.setCursor(strlen(headers[i]) + 1, i);
byte contentLen = contents[i].length();
byte availableSpace = lineLength - (strlen(headers[i]) + 1);
if (contentLen > availableSpace) {
for (byte j = 0; j < availableSpace; j++) {
byte charIndex = (scrollPos[i] + j) % contentLen;
lcd.print(contents[i][charIndex]);
}
scrollPos[i] = (scrollPos[i] + 1) % contentLen;
} else {
lcd.print(contents[i]);
for (byte j = contentLen; j < availableSpace; j++) {
lcd.print(" ");
}
}
}
}
}
在上面的程序中,我们实现了一个能够通过串口接收远程命令并实时更新 LCD 显示内容的系统监控界面。具体实现了下面的内容:
- 设置了串口通信,波特率为
115200
,允许接收来自计算机或其他设备的命令 - 实现了命令解析机制,可以识别格式为
"标题:内容"
的命令,如"UPTIME:428h8m0s"
- 根据接收到的命令,动态更新对应的内容区,同时重置滚动位置,确保内容从头开始显示
- 保留了之前开发的滚动显示功能,使得长内容依然能够完整展示
- 使用
String
类型替代了原来的固定字符数组,使程序能够处理不定长度的内容更新 - 程序初始化时显示
"waiting..."
,直到收到实际数据
这个版本的代码,实现了几个相对重要的细节功能:
- 实时更新:无需重启设备,远程系统可随时推送新的监控数据
- 选择性更新:可以单独更新某一行的内容,无需一次性发送所有信息
- 大小写兼容:通过
equalsIgnoreCase()
函数确保命令识别不受大小写影响 - 数据预处理:使用
trim()
函数移除接收数据中的多余空格和换行符
阶段二:完成信息获取服务设计
在完成了单片机和屏幕相关的程序后,我们来实现监控信息获取和传递相关的程序。
直接通过单片机获取其他硬件的信息会相对比较麻烦,比如我们想使用单片机获取 Windows、Linux、macOS、路由器、交换机等运行各种操作系统等设备。但是如果这些系统中,或可以访问这些系统的程序能够将系统相关的信息传递给单片机,单片机再在屏幕上完成信息呈现,就简单多了。
实现基础的硬件设备信息获取
这类传递信息的程序,我们一般叫客户端,而在服务器或设备上用于收集采集信息的程序,在古早的时候,我们会称乎它为“探针”。实现设备信息探针,使用任意一种编程语言都可以。我比较习惯使用 Go,简洁清晰、编译后的程序默认性能还不错,程序体积也非常小巧,大概 70 来行代码(stage/basic-probe/main.go):
package main
import (
"fmt"
"log"
"net/http"
"time"
"github.com/shirou/gopsutil/disk"
"github.com/shirou/gopsutil/host"
"github.com/shirou/gopsutil/mem"
"github.com/shirou/gopsutil/net"
)
func probeHandler(w http.ResponseWriter, r *http.Request) {
// 获取系统运行时间
bootTime, err := host.BootTime()
if err != nil {
http.Error(w, "无法获取系统启动时间", http.StatusInternalServerError)
return
}
uptime := time.Since(time.Unix(int64(bootTime), 0)).Round(time.Second)
// 获取内存使用情况
vmStat, err := mem.VirtualMemory()
if err != nil {
http.Error(w, "无法获取内存信息", http.StatusInternalServerError)
return
}
// 获取网络上传下载数据
netIOCounters, err := net.IOCounters(false)
if err != nil || len(netIOCounters) == 0 {
http.Error(w, "无法获取网络数据", http.StatusInternalServerError)
return
}
netIO := netIOCounters[0]
// 获取磁盘读写数据
diskIOCounters, err := disk.IOCounters()
if err != nil {
http.Error(w, "无法获取磁盘数据", http.StatusInternalServerError)
return
}
var readBytes, writeBytes uint64
for _, io := range diskIOCounters {
readBytes += io.ReadBytes
writeBytes += io.WriteBytes
}
fmt.Fprintf(w, "服务器运行时间: %v\n", uptime)
fmt.Fprintf(w, "内存使用: 已使用 %.2f%% (%v/%v)\n", vmStat.UsedPercent, formatBytes(vmStat.Used), formatBytes(vmStat.Total))
fmt.Fprintf(w, "网络上传数据: %v, 下载数据: %v\n", formatBytes(netIO.BytesSent), formatBytes(netIO.BytesRecv))
fmt.Fprintf(w, "磁盘读取数据: %v, 写入数据: %v\n", formatBytes(readBytes), formatBytes(writeBytes))
}
func formatBytes(b uint64) string {
const unit = 1024
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := unit, 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.2f %cB", float64(b)/float64(div), "KMGTPE"[exp])
}
func main() {
http.HandleFunc("/probe", probeHandler)
fmt.Println("服务器探针运行在 :8080/probe")
log.Fatal(http.ListenAndServe(":8080", nil))
}
当服务运行后,我们在浏览器中访问服务,能够得到当前设备的运行情况。
上文提到过,我们选择的这块屏幕(真是一分价钱一分货…)对于中文展示不够好,所以我们需要调整下输出的信息,将中文改为英文:(stage/probe-for-lcd/main.go):
fmt.Fprintf(w, "Server Uptime: %v\n", uptime)
fmt.Fprintf(w, "Memory Usage: %.2f%% (%v/%v)\n", vmStat.UsedPercent, formatBytes(vmStat.Used), formatBytes(vmStat.Total))
fmt.Fprintf(w, "Net Uploaded: %v, Downloaded: %v\n", formatBytes(netIO.BytesSent), formatBytes(netIO.BytesRecv))
fmt.Fprintf(w, "Disk Read: %v, Written: %v\n", formatBytes(readBytes), formatBytes(writeBytes))
再次运行程序,能够看到内容和预期中的一样,变成了英文。这样一来,屏幕在接收到指令和数据的时候,就能够正确更新并展示啦。
自动寻找单片机设备并更新数据
当我们将单片机和设备连接在一起之后,设备系统中会出现一个“串口设备”,我们需要通过“串口”来进行数据的交互。但是这个串口具体名称叫什么,因为我们使用的硬件制作厂商,使用的操作系统不同,实际会有很多变化的可能性。如果手动指定这个信息真就太麻烦了。
所以,我们需要在之前的代码上做进一步改动,增加比如“自动寻找可用串口设备”的功能(代码 stage/test-call-lcd/main.go):
package main
import (
"bufio"
"fmt"
"log"
"strings"
"time"
"go.bug.st/serial"
"go.bug.st/serial/enumerator"
)
func findSerialPort() (string, error) {
ports, err := enumerator.GetDetailedPortsList()
if err != nil {
return "", err
}
for _, port := range ports {
if port.IsUSB {
return port.Name, nil
}
}
return "", fmt.Errorf("no suitable USB serial port found")
}
func main() {
portName, err := findSerialPort()
if err != nil {
log.Fatal(err)
}
mode := &serial.Mode{
BaudRate: 115200,
}
port, err := serial.Open(portName, mode)
if err != nil {
log.Fatal(err)
}
defer port.Close()
fmt.Printf("Connected to serial port: %s\n", portName)
writer := bufio.NewWriter(port)
reader := bufio.NewScanner(port)
go func() {
for reader.Scan() {
line := reader.Text()
fmt.Println("Received from device:", line)
if strings.TrimSpace(line) == "SYNC_REQUEST" {
response := fmt.Sprintf("NOW:%s\n", time.Now().Format("15:04:05"))
writer.WriteString(response)
writer.Flush()
fmt.Println("Responded with time sync:", response)
}
}
}()
for {
dataItems := []string{
fmt.Sprintf("UPTIME:%s\n", "10days"),
"RAM:76% (18.2GB/24GB)\n",
"NET(U/D):102.5GB / 174.8GB\n",
"DISK(R/W):3.2TB / 972.4GB\n",
fmt.Sprintf("NOW:%s\n", time.Now().Format("15:04:05")),
}
for _, item := range dataItems {
_, err := writer.WriteString(item)
if err != nil {
log.Printf("Failed to write to serial port: %v", err)
}
writer.Flush()
time.Sleep(200 * time.Millisecond)
}
time.Sleep(1 * time.Second)
}
}
当我们再次启动程序的时候,Golang 程序便会自动寻找系统中已经就绪的串口设备,并尝试发送一些“模拟数据”,来验证单片机是否运行正常。
阶段三:组合并完善上面的程序
上面的代码中,我们已经将核心功能都基本完成了。接下来,我们来分别优化上面的代码,让程序可以运行的更加稳定。
优化单片机程序
我们先来优化单片机的程序(lcd/sentinel.ino):
#include <Wire.h>
#include <LiquidCrystal_I2C.h>
LiquidCrystal_I2C lcd(0x27, 20, 4);
const char* headers[5] = { "UPTIME", "RAM", "NET(U/D)", "DISK(R/W)", "NOW" };
char contents[5][64] = { "waiting...", "waiting...", "waiting...", "waiting...", "00:00:00" };
char tempContents[5][64]; // 临时缓冲区
bool contentUpdated[5] = { false };
byte scrollPos[4] = { 0 };
const byte lineLength = 20;
const unsigned long scrollDelay = 500;
const unsigned long displayInterval = 4000;
unsigned long previousMillis = 0;
unsigned long lastSwitchMillis = 0;
unsigned long lastTimeUpdateMillis = 0;
unsigned long lastSyncRequestMillis = 0;
const unsigned long timeUpdateInterval = 1000;
const unsigned long timeSyncInterval = 15000;
byte currentDisplay = 0;
unsigned long internalTimeMillis = 0;
unsigned long lastInternalMillis = 0;
void safeLCDPrint(byte col, byte row, const char* msg) {
lcd.setCursor(col, row);
char buffer[21];
snprintf(buffer, 21, "%-20s", msg);
lcd.print(buffer);
}
void setup() {
lcd.init();
lcd.backlight();
lcd.clear();
delay(300);
Serial.begin(115200);
safeLCDPrint(0, 0, "=[Mini Probe]= v0.1");
lastInternalMillis = millis();
memcpy(tempContents, contents, sizeof(contents));
}
void updateInternalTime() {
unsigned long currentMillis = millis();
internalTimeMillis += currentMillis - lastInternalMillis;
lastInternalMillis = currentMillis;
unsigned long totalSeconds = internalTimeMillis / 1000;
snprintf(contents[4], sizeof(contents[4]), "%02lu:%02lu:%02lu",
(totalSeconds / 3600) % 24, (totalSeconds / 60) % 60, totalSeconds % 60);
}
void requestTimeSync() {
Serial.println("SYNC_REQUEST");
}
void processSerial() {
if (Serial.available()) {
char buffer[80] = { 0 };
size_t len = Serial.readBytesUntil('\n', buffer, sizeof(buffer) - 1);
buffer[len] = '\0';
char* sep = strchr(buffer, ':');
if (sep != nullptr) {
*sep = '\0';
char* header = buffer;
char* data = sep + 1;
while (*header == ' ') header++;
while (*data == ' ') data++;
char* end;
end = header + strlen(header) - 1;
while (end > header && isspace(*end)) *(end--) = '\0';
end = data + strlen(data) - 1;
while (end > data && isspace(*end)) *(end--) = '\0';
for (byte i = 0; i < 5; i++) {
if (strcasecmp(header, headers[i]) == 0) {
strncpy(tempContents[i], data, sizeof(tempContents[i]) - 1);
tempContents[i][sizeof(tempContents[i]) - 1] = '\0';
contentUpdated[i] = true;
if (i == 4) { // 立即更新时间
int h, m, s;
if (sscanf(data, "%d:%d:%d", &h, &m, &s) == 3) {
internalTimeMillis = ((unsigned long)h * 3600UL + m * 60UL + s) * 1000UL;
lastInternalMillis = millis();
strncpy(contents[4], tempContents[4], sizeof(contents[4]));
contentUpdated[4] = false;
}
}
break;
}
}
}
}
}
void loop() {
unsigned long currentMillis = millis();
processSerial();
updateInternalTime();
if (currentMillis - lastSyncRequestMillis >= timeSyncInterval) {
lastSyncRequestMillis = currentMillis;
requestTimeSync();
}
if (currentMillis - lastSwitchMillis >= displayInterval) {
lastSwitchMillis = currentMillis;
currentDisplay = (currentDisplay + 1) % 4;
if (currentDisplay == 0) { // 完整显示一轮后才更新内容
for (byte i = 0; i < 4; i++) {
if (contentUpdated[i]) {
strncpy(contents[i], tempContents[i], sizeof(contents[i]));
contentUpdated[i] = false;
}
}
}
char headerLine[21];
snprintf(headerLine, 21, "%s:", headers[currentDisplay]);
safeLCDPrint(0, 1, headerLine);
scrollPos[currentDisplay] = 0;
}
if (currentMillis - previousMillis >= scrollDelay) {
previousMillis = currentMillis;
char* content = contents[currentDisplay];
byte contentLen = strlen(content);
char displayBuffer[21];
if (contentLen <= lineLength) {
snprintf(displayBuffer, 21, "%-20s", content);
} else {
for (byte i = 0; i < lineLength; i++) {
displayBuffer[i] = content[(scrollPos[currentDisplay] + i) % contentLen];
}
displayBuffer[lineLength] = '\0';
scrollPos[currentDisplay] = (scrollPos[currentDisplay] + 1) % contentLen;
}
safeLCDPrint(0, 2, displayBuffer);
}
if (currentMillis - lastTimeUpdateMillis >= timeUpdateInterval) {
lastTimeUpdateMillis = currentMillis;
char timeBuffer[21];
snprintf(timeBuffer, 21, "%20s", contents[4]);
safeLCDPrint(0, 3, timeBuffer);
}
}
在上面的程序中,我们实现了一个能够通过串口接收远程命令并更新 LCD 显示内容的系统监控界面。程序依旧设置了115200波特率的串口通信,允许接收来自计算机或其他设备的指令。我们约定了一套命令解析机制,可以识别"标题:内容"
格式的命令(如"UPTIME:428h8m0s"
),并根据接收到的命令动态更新对应的显示内容。
为了确保长内容能够完整展示,我们保留了之前开发的滚动显示功能。程序使用 String
类型替代了原来的固定字符数组,使其能够处理不定长度的内容更新。在设备初始化时,所有内容区域显示 "waiting..."
,直到接收到实际数据才进行更新。当接收到新数据时,程序会重置对应内容的滚动位置,确保内容从头开始显示。
这种设计同时实现了实时更新和选择性更新两个主要功能。
实时更新允许远程系统随时推送新的监控数据而无需重启设备;选择性更新使得可以单独更新某一行的内容,无需一次性发送所有信息。通过 equalsIgnoreCase()
函数确保命令识别不受大小写影响,同时使用 trim()
函数移除接收数据中的多余空格和换行符。
这个程序为我们的监控设备提供了与上位机或远程系统交互的能力,未来我们可以进一步扩展该程序,添加更多命令类型,或者实现双向通信功能,让设备不仅能接收命令,还能向上位机反馈设备状态。
优化 Golang 程序代码
接下来,我们来完善用 Go 编写的系统监控客户端,它负责收集主机系统信息并通过串口发送给我们之前开发的LCD显示设备。
这是整个监控系统的另一半核心组件,代码如下(lcd/main.go):
package main
import (
"bufio"
"fmt"
"log"
"strings"
"time"
"go.bug.st/serial"
"go.bug.st/serial/enumerator"
"github.com/shirou/gopsutil/disk"
"github.com/shirou/gopsutil/host"
"github.com/shirou/gopsutil/mem"
"github.com/shirou/gopsutil/net"
)
func findSerialPort() (string, error) {
ports, err := enumerator.GetDetailedPortsList()
if err != nil {
return "", err
}
for _, port := range ports {
if port.IsUSB {
return port.Name, nil
}
}
return "", fmt.Errorf("no suitable USB serial port found")
}
func formatBytes(b uint64) string {
const unit = 1024
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := unit, 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.2f %cB", float64(b)/float64(div), "KMGTPE"[exp])
}
func collectSystemInfo() ([]string, error) {
bootTime, err := host.BootTime()
if err != nil {
return nil, err
}
uptime := time.Since(time.Unix(int64(bootTime), 0)).Round(time.Second)
vmStat, err := mem.VirtualMemory()
if err != nil {
return nil, err
}
netIOCounters, err := net.IOCounters(false)
if err != nil || len(netIOCounters) == 0 {
return nil, fmt.Errorf("failed to get network data")
}
netIO := netIOCounters[0]
diskIOCounters, err := disk.IOCounters()
if err != nil {
return nil, err
}
var readBytes, writeBytes uint64
for _, io := range diskIOCounters {
readBytes += io.ReadBytes
writeBytes += io.WriteBytes
}
dataItems := []string{
fmt.Sprintf("UPTIME:%v\n", uptime),
fmt.Sprintf("RAM:%.2f%% (%s/%s)\n", vmStat.UsedPercent, formatBytes(vmStat.Used), formatBytes(vmStat.Total)),
fmt.Sprintf("NET(U/D):%s / %s\n", formatBytes(netIO.BytesSent), formatBytes(netIO.BytesRecv)),
fmt.Sprintf("DISK(R/W):%s / %s\n", formatBytes(readBytes), formatBytes(writeBytes)),
fmt.Sprintf("NOW:%s\n", time.Now().Format("15:04:05")),
}
return dataItems, nil
}
func main() {
portName, err := findSerialPort()
if err != nil {
log.Fatal(err)
}
mode := &serial.Mode{
BaudRate: 115200,
}
port, err := serial.Open(portName, mode)
if err != nil {
log.Fatal(err)
}
defer port.Close()
fmt.Printf("Connected to serial port: %s\n", portName)
writer := bufio.NewWriter(port)
reader := bufio.NewScanner(port)
go func() {
for reader.Scan() {
line := reader.Text()
fmt.Println("Received from device:", line)
if strings.TrimSpace(line) == "SYNC_REQUEST" {
response := fmt.Sprintf("NOW:%s\n", time.Now().Format("15:04:05"))
writer.WriteString(response)
writer.Flush()
fmt.Println("Responded with time sync:", response)
}
}
}()
for {
dataItems, err := collectSystemInfo()
if err != nil {
log.Printf("Error collecting system info: %v", err)
continue
}
for _, item := range dataItems {
_, err := writer.WriteString(item)
if err != nil {
log.Printf("Failed to write to serial port: %v", err)
}
writer.Flush()
time.Sleep(200 * time.Millisecond)
}
time.Sleep(1 * time.Second)
}
}
上面的程序首先尝试自动查找可用的USB串口设备,无需手动指定端口名称,在找到适合的串口后,与单片机端的设置相匹配,程序以 115200 的波特率建立连接。
程序的主要功能是定期收集系统状态信息(使用了跨平台的 gopsutil
指标采集库来获取这些系统信息),包括系统运行时间、内存使用情况、网络流量统计和磁盘读写量。这些信息通过collectSystemInfo()
函数采集,并格式化为与单片机约定的命令格式。
为了美观地显示字节单位,实现了一个 formatBytes()
函数,能够自动将字节数转换为 KB、MB、GB 等更易读的格式,也让 LCD 展示的信息更加简洁。
程序还实现了双向通信机制,通过一个单独的 goroutine
持续监听来自单片机的消息。当接收到 "SYNC_REQUEST"
时,程序会立即发送当前系统时间给单片机,实现时钟同步功能,确保了LCD显示器上的时间与主机系统保持一致。
在程序主循环中,每秒收集一次系统信息,并将各项指标逐个发送给单片机,中间添加短暂延迟以避免数据拥塞。这种设计使得监控数据能够平稳地更新到LCD显示器上。
到此为止,我们就完成了从系统信息采集到串口通信再到 LCD 显示的完整数据流,实现了一个小型但功能完备的系统监控解决方案。
阶段四:进行容器化封装
为了我们能够简单、可持续的使用这个程序,我们可以对上面的程序进行进一步容器化封装。这样程序就能够以容器的形式部署,避免了环境配置的复杂性,并提供了更好的可移植性和一致性。(容器配置:lcd/Dockerfile)
FROM golang:1.24-alpine AS builder
ENV LANG en_US.UTF-8
ENV LANGUAGE en_US.UTF-8
ENV LC_ALL=en_US.UTF-8
RUN echo '' > /etc/apk/repositories && \
echo "https://mirror.tuna.tsinghua.edu.cn/alpine/v3.10/main" >> /etc/apk/repositories && \
echo "https://mirror.tuna.tsinghua.edu.cn/alpine/v3.10/community" >> /etc/apk/repositories && \
echo "Asia/Shanghai" > /etc/timezone
RUN apk add --no-cache gcc musl-dev linux-headers
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod tidy
COPY . .
RUN go build -o serial_monitor .
FROM alpine:latest
RUN apk add --no-cache libusb-dev
WORKDIR /app
COPY --from=builder /app/serial_monitor .
CMD ["./serial_monitor"]
准备好上面的文件后,我们执行下面的命令,就能够完成镜像的构建啦:
docker build -t soulteary/mini-probe .
在上面的 Dockerfile 中,我们采用了多阶段构建的方式来最小化最终镜像的大小。
第一阶段使用 golang:1.24-alpine
作为构建环境,编译我们的 Go 应用程序。第二阶段则使用精简的 alpine 镜像作为运行环境,只包含了运行程序所必需的组件。
构建阶段中,我们首先配置了适合国内用户使用的软件源和时区,然后安装了编译所需的 gcc 和相关开发库。接着,将源代码复制到容器里、解决依赖关系并编译生成可执行文件。
在运行阶段的容器中,我们只安装了必要的 libusb-dev
库以支持 USB 设备方式的使用,然后从构建阶段复制编译好的可执行文件。
这样一顿操作,最终生成的容器镜像体积小、启动快。并且,当我们在运行容器时映射设备后,容器程序就能够直接访问主机的USB设备啦。例如:
docker run --device=/dev/ttyUSB0 -it soulteary/mini-probe
部署测试
部署测试之前,我们需要先将单片机和待监控测试设备连接在一起。
因为我们只想让容器中的程序访问某一个固定的串口设备,所以这里我没有将所有串口设备都映射给容器,而是通过 lsusb -cui
命令先获取所有设备的列表,再进行指定设备的容器环境映射。
# lsusb -cui
|__usb1 1d6b:0002:0404 09 2.00 480MBit/s 0mA 1IF (ehci_hcd 0000:00:12.0) hub
|__1-1 0438:7900:0018 09 2.00 480MBit/s 100mA 1IF ( ffffffd1ffffffb2ffffffdbffffffad) hub
|__1-1.2 f400:f400:0110 00 2.10 480MBit/s 300mA 1IF (Kingston DataTraveler 3.0 60A44C3FB03BB011298F0103)
1-1.2:1.0 (IF) 08:06:50 2EPs () usb-storage host9
|__usb2 1d6b:0002:0404 09 2.00 480MBit/s 0mA 1IF (Linux 4.4.302+ xhci-hcd xHCI Host Controller 0000:00:10.0) hub
|__2-4 2341:0043:0001 02 1.10 12MBit/s 100mA 2IFs (Arduino (www.arduino.cc) USBDevice 557393237373511171B2)
2-4:1.0 (IF) 02:02:01 1EP () cdc_acm tty/ttyACM0
2-4:1.1 (IF) 0a:00:00 2EPs () cdc_acm
上面的程序日志中,我们能够看到有一行 Arduino
信息相关的设备:
|__2-4 2341:0043:0001 02 1.10 12MBit/s 100mA 2IFs (Arduino (www.arduino.cc) USBDevice 557393237373511171B2)
2-4:1.0 (IF) 02:02:01 1EP () cdc_acm tty/ttyACM0
2-4:1.1 (IF) 0a:00:00 2EPs () cdc_acm
这个设备下面的 tty/ttyACM0
就是串口设备的名称,将名称带入容器运行命令中:
docker run -d --device=/dev/ttyACM0 soulteary/mini-probe
程序启动结束,稍等一两秒,单片机和屏幕就可以开始执行设备监控和信息展示的任务啦。
最后
希望你玩的开心,我们下篇文章再见。
—EOF