ESP32C3 Linux IP Display

🖥️ ESP32C3 Linux IP Display

A USB-powered OLED display for showing Linux server IP address in real time.
当服务器没有显示器、IP 经常变、无法固定 IP 时,只需插上这块小屏幕即可立刻看到当前服务器的 LAN/WAN IP。

📸 实物展示
在这里插入图片描述

使用 ESP32C3 Super Mini + 72×40 SSD1306 OLED,USB 供电 + USB CDC 串口通讯。

✨ Features

🎯 自动检测 USB ACM 设备(VID=303A)

📡 实时显示 IP (LAN/WLAN)

🔄 每秒刷新,不占用 CPU

💡 新消息 LED 闪烁指示

🔌 即插即用,断开自动恢复

🖥️ OLED 自动换行,支持 4 行内容

🛠️ 完整的 systemd 服务,开机自启

🔧 代码仅依赖 Python3 + ESP32 Arduino

📦 目录结构
.
├── esp32_firmware/
│ └── ip_display.ino # ESP32C3 程序
├── linux_client/
│ └── acm_report.py # Linux 端脚本
├── systemd/
│ └── acm_report.service # systemd 启动脚本
└── README.md

🚀 项目原理
Linux Server ESP32C3 + OLED
┌────────────────────────────┐ ┌─────────────────────────┐
│ Python Script │ USB │ USB CDC 串口接收字符串 │
│ 获取所有网卡 IPv4 地址 │──────▶│ 分行显示到 SSD1306 OLED │
│ 格式化为 L:xxx W:xxx │ │ 最近 1 分钟有数据则闪灯 │
│ 写入 /dev/ttyACM* │ └─────────────────────────┘
└────────────────────────────┘

ESP32C3 通过串口接受字符串并显示,Linux 每秒发一次 IP 状态。

🛠️ 1. ESP32C3 / ESP32C3 Super Mini 固件

硬件连接:

Signal ESP32C3 Pin
SDA GPIO5
SCL GPIO6
LED GPIO8
OLED I2C (0x3C)

使用 Arduino 框架。

📌 代码(ip_display.ino)

#include <U8g2lib.h>

#ifdef U8X8_HAVE_HW_I2C
#include <Wire.h>
#endif

#define SDA_PIN 5
#define SCL_PIN 6

// LED 引脚(按你要求用 IO8)
#define LED_PIN 8

// 按你之前的屏幕型号
U8G2_SSD1306_72X40_ER_F_HW_I2C u8g2(U8G2_R0, /* reset=*/ U8X8_PIN_NONE);

const int MAX_LINES = 4;
String linesBuf[MAX_LINES];
int16_t dispWidth;

// LED 闪烁控制
unsigned long lastMsgTime = 0;        // 最近一次收到新消息的时间
const unsigned long activeWindow = 60000UL; // 1 分钟内有新消息才闪灯
const unsigned long blinkInterval = 500UL;  // 闪烁周期(毫秒)
bool ledState = false;

void u8g2_prepare(void) {
  u8g2.setFont(u8g2_font_6x10_tf);
  u8g2.setFontRefHeightExtendedText();
  u8g2.setDrawColor(1);
  u8g2.setFontPosTop();
  u8g2.setFontDirection(0);
}

// 去掉字符串右侧空白
void trimRight(String &s) {
  while (s.length() > 0) {
    char c = s.charAt(s.length() - 1);
    if (c == ' ' || c == '\t' || c == '\r' || c == '\n') {
      s.remove(s.length() - 1);
    } else {
      break;
    }
  }
}

// 把一整行 text 按显示宽度拆成最多 4 行
void wrapTextIntoLines(String text) {
  for (int i = 0; i < MAX_LINES; i++) linesBuf[i] = "";

  text.trim();  // 去掉首尾空白
  for (int lineNo = 0; lineNo < MAX_LINES && text.length() > 0; lineNo++) {
    // 去掉前导空格
    while (text.length() > 0 && text.charAt(0) == ' ') text.remove(0, 1);
    if (text.length() == 0) break;

    int fit = text.length();
    // 找最大能放得下的前缀
    while (fit > 0) {
      String part = text.substring(0, fit);
      if (u8g2.getStrWidth(part.c_str()) <= dispWidth) break;
      fit--;
    }
    if (fit == 0) {
      // 理论上不会发生,保险:至少放 1 个字符
      fit = 1;
    } else {
      // 尽量在空格处断行
      int lastSpace = text.substring(0, fit).lastIndexOf(' ');
      if (lastSpace > 0) fit = lastSpace;
    }

    String chunk = text.substring(0, fit);
    trimRight(chunk);
    linesBuf[lineNo] = chunk;

    text = text.substring(fit);
  }
}

void displayText(const String &s) {
  wrapTextIntoLines(s);

  u8g2.clearBuffer();
  const int line_spacing = 10; // 6x10 字体行高约 10 像素
  for (int i = 0; i < MAX_LINES; i++) {
    if (linesBuf[i].length() > 0) {
      u8g2.drawStr(0, i * line_spacing, linesBuf[i].c_str());
    }
  }
  u8g2.sendBuffer();
}

void setup() {
  Serial.begin(115200);
  Serial.setTimeout(200);       // readStringUntil 超时

  pinMode(LED_PIN, OUTPUT);
  digitalWrite(LED_PIN, LOW);   // 初始灭灯

  Wire.begin(SDA_PIN, SCL_PIN);
  u8g2.begin();
  u8g2_prepare();

  dispWidth = u8g2.getDisplayWidth();

  displayText("Ready. Send text with newline.");
  lastMsgTime = millis();       // 上电时算作有一次“消息”,先允许闪灯
}

void loop() {
  // 1. 串口读取和屏幕更新
  if (Serial.available()) {
    // 和你测试回显程序一样,用 '\n' 作为一行结束
    String line = Serial.readStringUntil('\n');
    line.trim();
    if (line.length() > 0) {
      Serial.print("Display: [");
      Serial.print(line);
      Serial.println("]");
      displayText(line);

      // 记录最近收到消息的时间
      lastMsgTime = millis();
    }
  }

  // 2. LED 闪烁逻辑:1 分钟内有新消息则闪烁,超过 1 分钟不闪(灭)
  unsigned long now = millis();
  if (now - lastMsgTime <= activeWindow) {
    // 在“有消息”的时间窗内,按照 blinkInterval 闪烁
    static unsigned long lastBlink = 0;
    if (now - lastBlink >= blinkInterval) {
      lastBlink = now;
      ledState = !ledState;
      digitalWrite(LED_PIN, ledState ? HIGH : LOW);
    }
  } else {
    // 超过 1 分钟没新消息,保持熄灭
    ledState = false;
    digitalWrite(LED_PIN, LOW);
  }

  delay(20);
}

🖥️ 2. Linux 端—IP 自动上报脚本

位置:

/usr/local/bin/acm_report.py

功能:

自动发现 VID=303A 的 ACM

每秒扫描 IPv4,提取 enp*(LAN)、wlp*(WLAN)

写入 JSON 格式可读串口文本

📌 代码

#!/usr/bin/env python3
import os
import time
import subprocess
import glob

TARGET_VID = "303a"
SLEEP_TIME = 1

def get_ip_map():
    """扫描所有接口 IPv4,返回字典:iface -> ip"""
    ipmap = {}
    try:
        out = subprocess.check_output(["ip", "-4", "-o", "addr"]).decode()
        for line in out.splitlines():
            parts = line.split()
            iface = parts[1]
            ip = parts[3].split("/")[0]
            ipmap[iface] = ip
    except Exception:
        pass
    return ipmap


def pick_ip_for_prefix(ipmap, prefix):
    """选择名字以 prefix 开头的网卡 IPv4 地址"""
    for iface, ip in ipmap.items():
        if iface.startswith(prefix):
            return ip
    return "0.0.0.0"


def find_acm_device():
    """查找 VID=303A 的 /dev/ttyACM* 设备"""
    for acm in glob.glob("/dev/ttyACM*"):
        sysfs_path = f"/sys/class/tty/{os.path.basename(acm)}/device/../idVendor"
        if os.path.exists(sysfs_path):
            try:
                with open(sysfs_path, "r") as f:
                    if f.read().strip().lower() == TARGET_VID:
                        return acm
            except:
                pass
    return None


def main():
    acm_dev = None

    while True:
        # Step 1:找 ACM
        if acm_dev is None:
            acm_dev = find_acm_device()
            if acm_dev is None:
                print("未找到 VID=303A 的 ACM 设备... 等待中...")
                time.sleep(SLEEP_TIME)
                continue
            else:
                print(f"找到设备:{acm_dev}")

        # Step 2:获取 IP 地址
        ipmap = get_ip_map()

        lan_ip = pick_ip_for_prefix(ipmap, "enp")   # LAN
        wan_ip = pick_ip_for_prefix(ipmap, "wlp")   # WAN(你的系统用 wlp2s0)

        msg = f"L:{lan_ip} W:{wan_ip}"

        # Step 3:写入 ACM
        try:
            os.system(f'echo "{msg}" > {acm_dev}')
            print("写入:", msg)
        except Exception as e:
            print("写入失败:", e)

        time.sleep(SLEEP_TIME)


if __name__ == "__main__":
    main()

🔧 3. systemd 服务(开机自启)

创建:

sudo nano /etc/systemd/system/acm_report.service

内容:

[Unit]
Description=ACM Report Service
After=network.target

[Service]
Type=simple
ExecStart=/usr/bin/python3 /usr/local/bin/acm_report.py
Restart=always
RestartSec=2
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

启用:

sudo systemctl daemon-reload
sudo systemctl enable acm_report.service
sudo systemctl start acm_report.service

查看状态:

sudo systemctl status acm_report.service

🧩 效果展示

插上 ESP32C3 后屏幕将自动显示:

在这里插入图片描述

如果一分钟内收到新消息,LED 会闪烁指示设备在线。

🔌 热插拔无障碍

即使 Linux 重启、断开 USB、换端口,也能自动识别:

自动寻找 /dev/ttyACM*

自动验证 idVendor = 303A

自动恢复显示

无需人工干预。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值