本文使用「署名 4.0 国际 (CC BY 4.0)」许可协议,欢迎转载、或重新修改使用,但需要注明来源。 [署名 4.0 国际 (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/deed.zh) 本文作者: 苏洋 创建时间: 2025年04月09日 统计字数: 21936字 阅读时间: 44分钟阅读 本文链接: https://soulteary.com/2025/04/09/light-up-the-status-a-few-costs-to-get-home-nas-electronic-monitoring-screen.html ----- # 把状态“亮”出来:几十块搞定家用 NAS 电子监控屏 分享一篇轻松好玩的内容,用几十块钱的成本为原本不具备显示屏的设备,增加电子显示屏。 ## 写在前面 利用本篇文章的思路,举一反三,不仅可以为 NAS 增加屏幕,也可以为路由器、交换机、小主机等一系列没有显示器,又不想外接显示器或者打开网页浏览的设备进行低成本扩展,快速获取设备运行关键信息。 虽然将电子设备通过 NMAP 或者通过 Exporter 接入监控系统,并配合通知系统来仅在设备出现异常时进行提醒更为高效。但很多时候,如果能够不打开网页就能看到设备的运行状况,总归会让人更安心一些。 ![目前的使用情况](https://attachment.soulteary.com/2025/04/09/mini-lcd.jpg) 前一阵我又入手了两台 Gen10,目前家里已经有三台这样的设备和若干其他的设备在“Headless 运行”,而这些设备都缺少一个抬头就能看到信息的“可视化”的监控方式。 最近在知乎上看到一个问题:“生活中的哪些瞬间让你感受到了「开源精神」还不错?”。我觉得能够折腾有趣的事情,并将过程和做好的小作品分享给更多人,让大家都能参与进来一起折腾,这种感觉就很棒。 所以,就有了这篇文章。本文中的所有代码和内容都已开源,感兴趣的朋友可以自取:[soulteary/mini-probe](https://github.com/soulteary/mini-probe) 好了,让我们开始实践吧。 ## 准备材料 除了被监控的设备外,我们需要一块用于显示内容的屏幕(液晶屏),一块用来驱动显示屏的开发板,以及一根连接设备和开发板的数据线: ![本文使用的“材料”](https://attachment.soulteary.com/2025/04/09/prepare-material.jpg) 我使用了一块 Arduino UNO 开发板,实际上使用 NodeMCU、各种派也是一样的,推荐有什么用什么,什么便宜用什么,个人建议可以购买国产改进版试试看(亲测没啥差异);液晶屏我选择的是 IIC 2004 LCD,类似的还有 IIC 1602 LCD,主要差别在于尺寸和可显示的字符数;数据线选择开发板附带的,或者家里闲置的就行。 ![试了试国产开发板也蛮好的](https://attachment.soulteary.com/2025/04/09/adaptable-board.jpg) 被监控的设备这边,需要能够运行 Docker,这样我们的使用、迁移、复用和管理都能省很多精力。 ![部署单片机程序](https://attachment.soulteary.com/2025/04/09/deploy.jpg) 至于代码部署,我们使用官方提供的 Arduino IDE,选择好单片机型号,然后将代码复制粘贴到编辑器里,点击“上传”按钮就好了,没有什么复杂的事情。 ## 阶段一:完成单片机和液晶屏幕可控程序设计 第一阶段的事情,我们需要完成单片机和屏幕的程序,并让单片机能够通过指令动态更新屏幕内容。 ### 单片机和液晶屏的 “Hello World” ![点亮屏幕,测试下功能](https://attachment.soulteary.com/2025/04/09/test1.jpg) 熟悉单片机的同学可以跳过这部分内容,直接阅读后面的几个阶段内容。还不熟悉单片机的同学可以从下面不到 30 行代码来开始熟悉这个“点灯(屏幕)”程序(代码开源在[stage/test-lcd/hello-world.ino](https://github.com/soulteary/mini-probe/blob/main/stage/test-lcd/hello-world.ino)): ```cpp #include #include // 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”,它不仅展示了基本的文本输出能力,也验证了屏幕初始化、光标定位和内容刷新等(硬件开发板和屏幕的)基本功能是否正常工作。 着急的同学先别急,代码函数封装这些以往写程序时,我们会做的“基操”,可以等最终实现的时候再做。过早优化没有必要,因为耐心往下看,我们的程序会大不一样。 另外,这里之所以使用英文,是因为我们选择的这类最基础的显示屏不支持中文字库(使用变通方案展示不够美观),后续如果有相关文章,我会分享其他类型屏幕的折腾方案。 ### 完善信息呈现交互(溢出内容滚动) ![让超过屏幕的内容滚动起来](https://attachment.soulteary.com/2025/04/09/test2.jpg) 因为我们使用的屏幕只有 20x4 的内容展示区域,所以当我们需要呈现的内容过长时,就会出现信息展示不够完整的情况。最基础的交互方案是:采取信息滚动。 为了更好的进行测试验证,我们将短文诗和代码一起更新(代码开源在 [stage/test-lcd/overflow-scroll.ino](https://github.com/soulteary/mini-probe/blob/main/stage/test-lcd/overflow-scroll.ino)): ```cpp #include #include 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 设计 ![设计基础的 UI 界面](https://attachment.soulteary.com/2025/04/09/test3.jpg) 以我预期的使用方式为例,我会将设备的“运行时间”、“内存使用情况”、“网络使用情况”、“磁盘使用情况”都打印在屏幕上。 所以,我们可以用一些模拟(Mock)信息来对程序进行更新,并适当调整展示相关的参数,让整个交互稍微令人观看的时候舒服些。(代码[stage/test-lcd/lcd-info-scroll.ino](https://github.com/soulteary/mini-probe/blob/main/stage/test-lcd/lcd-info-scroll.ino)) ```cpp #include #include 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 屏幕按远程指令更新内容 ![实现从远程指令更新屏幕内容](https://attachment.soulteary.com/2025/04/09/update-by-remote-cmd.jpg) 在前面的文章里,我们能够通过程序来控制单片机在屏幕上呈现内容,并且能够控制程序处理好溢出屏幕外的内容,现在再实现好遵循远程命令更新内容,基本的单片机程序就完成了(代码开源在[stage/test-lcd/wait-for-call.ino](https://github.com/soulteary/mini-probe/blob/main/stage/test-lcd/wait-for-call.ino)) ```cpp #include #include 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](https://github.com/soulteary/mini-probe/blob/main/stage/basic-probe/main.go)): ```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)) } ``` ![程序运行后的效果](https://attachment.soulteary.com/2025/04/09/probe-1.jpg) 当服务运行后,我们在浏览器中访问服务,能够得到当前设备的运行情况。 上文提到过,我们选择的这块屏幕(真是一分价钱一分货…)对于中文展示不够好,所以我们需要调整下输出的信息,将中文改为英文:([stage/probe-for-lcd/main.go](https://github.com/soulteary/mini-probe/blob/main/stage/probe-for-lcd/main.go)): ```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)) ``` ![再次程序运行后的效果](https://attachment.soulteary.com/2025/04/09/probe-2.jpg) 再次运行程序,能够看到内容和预期中的一样,变成了英文。这样一来,屏幕在接收到指令和数据的时候,就能够正确更新并展示啦。 ### 自动寻找单片机设备并更新数据 ![实现从远程指令更新屏幕内容](https://attachment.soulteary.com/2025/04/09/test-remote-cmd.jpg) 当我们将单片机和设备连接在一起之后,设备系统中会出现一个“串口设备”,我们需要通过“串口”来进行数据的交互。但是这个串口具体名称叫什么,因为我们使用的硬件制作厂商,使用的操作系统不同,实际会有很多变化的可能性。如果手动指定这个信息真就太麻烦了。 所以,我们需要在之前的代码上做进一步改动,增加比如“自动寻找可用串口设备”的功能(代码 [stage/test-call-lcd/main.go](https://github.com/soulteary/mini-probe/blob/main/stage/test-call-lcd/main.go)): ```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](https://github.com/soulteary/mini-probe/blob/main/lcd/sentinel.ino)): ```cpp #include #include 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](https://github.com/soulteary/mini-probe/blob/main/lcd/main.go)): ```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](https://github.com/soulteary/mini-probe/blob/main/lcd/Dockerfile)) ```Docker 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 docker build -t soulteary/mini-probe . ``` 在上面的 Dockerfile 中,我们采用了多阶段构建的方式来最小化最终镜像的大小。 第一阶段使用 `golang:1.24-alpine` 作为构建环境,编译我们的 Go 应用程序。第二阶段则使用精简的 alpine 镜像作为运行环境,只包含了运行程序所必需的组件。 构建阶段中,我们首先配置了适合国内用户使用的软件源和时区,然后安装了编译所需的 gcc 和相关开发库。接着,将源代码复制到容器里、解决依赖关系并编译生成可执行文件。 在运行阶段的容器中,我们只安装了必要的 `libusb-dev` 库以支持 USB 设备方式的使用,然后从构建阶段复制编译好的可执行文件。 这样一顿操作,最终生成的容器镜像体积小、启动快。并且,当我们在运行容器时映射设备后,容器程序就能够直接访问主机的USB设备啦。例如: ```bash docker run --device=/dev/ttyUSB0 -it soulteary/mini-probe ``` ## 部署测试 部署测试之前,我们需要先将单片机和待监控测试设备连接在一起。 因为我们只想让容器中的程序访问某一个固定的串口设备,所以这里我没有将所有串口设备都映射给容器,而是通过 `lsusb -cui` 命令先获取所有设备的列表,再进行指定设备的容器环境映射。 ```bash # 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` 信息相关的设备: ```bash |__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` 就是串口设备的名称,将名称带入容器运行命令中: ```bash docker run -d --device=/dev/ttyACM0 soulteary/mini-probe ``` 程序启动结束,稍等一两秒,单片机和屏幕就可以开始执行设备监控和信息展示的任务啦。 ## 最后 希望你玩的开心,我们下篇文章再见。 —EOF