分享一篇轻松好玩的内容,用几十块钱的成本为原本不具备显示屏的设备,增加电子显示屏。

写在前面

利用本篇文章的思路,举一反三,不仅可以为 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 设计

设计基础的 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 设计的优点是清晰度相对较高,我们可以一目了然地看到各类系统信息的分类,同时通过滚动技术解决了内容溢出的问题。而对于 RAMNET(U/D) 等较长数据,系统会自动滚动显示完整信息。

并且,这个简单的设计还具有很好的扩展性,只需修改 headerscontents 数组中的内容,就能适应不同的监控需求。未来我们可以进一步完善这个界面,例如添加刷新实时数据的功能,或者增加用户交互控制选项。

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