嵌入式操作系统 | C 网关:串口 ⇄ UDP 转发(先用 UDP 当上位机替身)
本课程开源地址(Gitee) :https://gitee.com/fujianxinxi/qianrushixitongyingyongkaifa.git
课件、示例代码与验收脚本都在该仓库,可直接
git clone或下载 ZIP 使用。
模块七 · 2 学时 · 环境:Ubuntu(gcc + 课时 15 装的 libubox)+ socat 虚拟串口
一句话概述 :写项目里的"中间人"------C 网关 。它一头打开串口 /tmp/ttyV0(设备那边),一头开一个 UDP 口(上位机那边),把上位机的命令原样写进串口 、把设备的状态帧转回上位机 。下课时你会看到:一条 UDP 命令 → 网关照转 → 设备面板的灯亮了 → 状态又回到上位机。
本节目标
- 说清网关的职责(协议转发 + 校验 + 统计),会画"上位机 ⇄ 网关 ⇄ 设备"的数据流
- 会在一个 uloop 里同时管两个 fd(串口 + UDP)------课时 16/17/23 的知识在这里合体
- 会写 UDP 的收发(
socket/bind/recvfrom/sendto),知道为什么先用 UDP 当上位机替身 - 会 C 版"切帧 + CRC 校验"(和课时 26 的 Python 版对照着看,算法是同一套)
一、讲解内容
1.1 知识讲解
网关在项目里的位置。 前面两节课:设备(面板)会执行命令了,但只有串口能控制它;现在我们加一层"中间人",让网络上的程序也能控制它:
上位机(本节先用 UDP 命令行工具)
│ UDP 数据报(同一套帧:55 AA | TYPE | LEN | 数据 | CRC16)
▼
┌─────────────────────────────┐
│ C 网关(uloop 事件循环) │ ← 本节课写这个
│ · 串口 fd:读设备 / 写命令 │
│ · UDP fd:收命令 / 发状态 │
└─────────────────────────────┘
│ 9600-8-N-1 串口(socat 虚拟串口)
▼
设备面板(课时 26 的 device_panel_serial.py)
为什么先用 UDP,而不是直接上 TCP?(下节课再上 TCP)
| UDP | TCP | |
|---|---|---|
| 要不要"连接" | 不要,直接 sendto |
要 listen/accept,还要管多客户端 |
| 数据边界 | 一个数据报 = 一帧,天然不粘连 | 是字节流:可能粘包、可能半包,要自己切 |
| 代码量 | 30 行够 | 要多 60~80 行 |
所以顺序是:先用 UDP 把"网关在中间转发"这件事讲通 (本课),下节课再把 TCP 命令通道加上(真上位机用 TCP 更合适,因为可靠)。
本节的核心:一个 uloop 同时管两个 fd。 课时 16 管过 UDP 的 fd、课时 23 管过串口的 fd,这次是两个一起管------这就是事件驱动的价值:串口有数据就读串口,UDP 来了就读 UDP,谁也不用等谁:
c
ufd_ser.fd = ser_fd; ufd_ser.cb = on_serial_readable; uloop_fd_add(&ufd_ser, ULOOP_READ);
ufd_udp.fd = udp_fd; ufd_udp.cb = on_udp_readable; uloop_fd_add(&ufd_udp, ULOOP_READ);
uloop_run(); /* 之后就是"谁有事谁回调" */
UDP 的四个函数(本课只用到这四个):
| 函数 | 作用 | 本课怎么用 |
|---|---|---|
socket(AF_INET, SOCK_DGRAM, 0) |
建一个 UDP 套接字 | 网关启动时建一次 |
bind(fd, addr, len) |
绑定 0.0.0.0:9200 |
这样本机和 Windows 宿主机都能发命令进来 |
recvfrom(fd, buf, ..., &src, &slen) |
收一个数据报,顺带得到对方地址 | 收到命令,并记下"上位机在哪" |
sendto(fd, buf, len, 0, &peer, len) |
发给指定地址 | 把设备状态转回上位机 |
"记住最近的上位机地址"------本节的一个简化设计(也有坑) :设备每秒都在上报状态,但 UDP 里网关没法"主动知道"上位机在哪 ,所以本课的简化做法是:谁给我发过命令,我就记住他的地址,之后设备状态都发给他 (代码里的 peer / has_peer)。
坑(下节课就修):任何发过包的人都会顶掉这个地址------比如你用一个临时端口的脚本发了一帧坏数据,网关就会把状态发到那个"已经退出"的地址,真正的上位机反而收不到了。课时 29 会改成"上报目标固定/从 uci 读",更靠谱。
C 版的"切帧"和 Python 版是同一套算法,只是写法更"手工":
| 步骤 | 课时 26(Python) | 本课(C) |
|---|---|---|
| 找帧头 | buf.find(b"\x55\xaa") |
不是 55 AA 就把缓冲区左移一个字节 (memmove)继续找 |
| 判长度 | total = 2 + 2 + buf[3] + 2 |
一样;另外加一条"长度 > 64 就丢掉重找" |
| 半帧 | 留着,下次轮询再拼 | 一样:留着,等下次 read 补上 |
| 校验 | crc16_modbus(frame[2:4+len]) |
crc16_modbus(rxbuf + 2, 2 + len) |
本课的"三个数字"(打印在退出统计里,是排查的利器):串口收了多少帧、转给上位机多少帧、上位机收了多少帧、转给串口多少帧。对不上就说明某一环丢了数据。
1.2 操作步骤(一条命令一步,看现象)
第 0 步:环境检查(本节要 gcc、libubox、socat、pyserial)
shell
gcc --version | head -1 # 要能打印版本
ls /usr/local/include/libubox/uloop.h # 课时 15 装的 libubox
which socat # 造虚拟串口用
python3 -c "import serial; print('pyserial OK')" # 设备面板要用
现象:四行都正常。缺东西就补(照抄版清单见课时 24 附录 F):
shell
sudo apt update
sudo apt install -y build-essential cmake pkg-config socat # C 侧
sudo apt install -y python3-pyqt5 python3-serial xvfb # 设备面板要用
第 1 步:编译网关
shell
cd ~/lab/课时27-网关骨架串口与UDP
gcc -Wall -Wextra -o gateway gateway.c -lubox
ls -l gateway
现象:编译无输出(无警告无错误)即成功,ls 能看到 gateway。
报
cannot find -lubox→ 课时 15 的库没装好或忘了sudo ldconfig;报uloop_fd_handler之类参数不匹配 → 回调签名要写成(struct uloop_fd *f, unsigned int events)。
第 2 步:三个终端准备(A 串口 / B 设备面板 / C 网关)
shell
# 终端 A:造虚拟串口(保持运行)
socat -d -d pty,raw,echo=0,link=/tmp/ttyV0 pty,raw,echo=0,link=/tmp/ttyV1
# 终端 B:设备面板(课时 26 的程序;要在桌面里,或用 xvfb 代替)
cd ~/lab/课时26-设备面板接串口
python3 device_panel_serial.py
# 终端 C:网关
cd ~/lab/课时27-网关骨架串口与UDP
./gateway /tmp/ttyV0 9200
网关的现象(实测):
text
网关已启动:串口 /tmp/ttyV0 @ 9600 ⇄ UDP 0.0.0.0:9200(Ctrl+C 退出)
方向说明:上位机 UDP 发命令 → 网关 → 串口 → 设备;设备的状态帧 → 网关 → 上位机
串口 → 上位机(10 字节):55 AA 80 04 00 00 00 FD 2F 9A
(还没有上位机发来过命令,这一帧先不转发)
串口 → 上位机(10 字节):55 AA 80 04 00 00 00 FE 6F 9B
(还没有上位机发来过命令,这一帧先不转发)
这两行"先不转发"很重要:设备已经在每秒上报了,但网关还不知道"上位机在哪"------等你发过一次命令,它就记住了。
第 3 步:上位机(UDP)发命令------点灯
shell
cd ~/lab/课时27-网关骨架串口与UDP
python3 -u udp_client.py led on
现象(实测,客户端这边):
text
发送到 127.0.0.1:9200:55 AA 01 01 01 E0 50
收到 55 AA 80 04 01 00 00 FE 6E 67 → 设备状态:灯 亮,风扇 0 档,温度 25.4 ℃
收到 55 AA 80 04 01 00 00 FE 6E 67 → 设备状态:灯 亮,风扇 0 档,温度 25.4 ℃
收到 55 AA 80 04 01 00 00 FF AF A7 → 设备状态:灯 亮,风扇 0 档,温度 25.5 ℃
网关这边(实测):
text
上位机地址 = 127.0.0.1:9100
上位机 → 串口(7 字节):55 AA 01 01 01 E0 50
串口 → 上位机(10 字节):55 AA 80 04 01 00 00 FE 6E 67
已转给上位机 127.0.0.1:9100
设备面板那边:灯变绿(和课时 26 命令行点灯一样的效果,只是这次命令从网络来)。

第 4 步:只听模式(看设备每秒上报被转成 UDP)
shell
python3 -u udp_client.py listen # Ctrl+C 退出
现象(实测):每 1 秒一行 收到 55 AA 80 04 ... → 设备状态:灯 亮,风扇 2 档,温度 xx.x ℃。
listen只监听、不"注册"地址;如果网关还没记住任何地址,先发一条命令(比如status)。
第 5 步:其余命令都试一遍
shell
python3 -u udp_client.py fan 2 # 风扇 2 档
python3 -u udp_client.py status # 查状态
python3 -u udp_client.py reset # 复位(灯灭、风扇停)
第 6 步:坏帧实验(网关会把它挡住)
shell
python3 -u -c "
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.sendto(bytes.fromhex('55aa010101e051'), ('127.0.0.1', 9200)) # CRC 故意写错(本应 50)
print('已发出 CRC 错的帧:55 AA 01 01 01 E0 51')
s.close()
"
现象(网关日志,实测):上位机帧 CRC 不对(算出 50E0,帧里 51E0),丢弃
→ 坏数据没有进串口,设备不受影响。这就是"网关要校验"的意义。
第 7 步:看清"地址被顶掉"这个简化设计的坑
上一步那个临时脚本用的是随机高位端口 ,它同样会把网关的 peer 顶掉。现象(实测):网关把下一帧状态发给了那个端口------
text
串口 → 上位机(10 字节):55 AA 80 04 01 02 00 FB 0F A4
已转给上位机 127.0.0.1:33199 ← 临时脚本的端口,不是我们的 9100
我们的
listen会"莫名其妙收不到"了;再发一条命令(比如status)就把地址抢回来。这个坑下节课(29)正式修:把上报目标改成固定地址/从 uci 读,而不是"谁说话听谁的"。
第 8 步:停网关,看统计
在终端 C 按 Ctrl+C,现象(实测):
text
网关退出。统计:串口收 12 帧、转给上位机 10 帧;上位机收 3 帧、转给串口 3 帧
数字对不上是正常的(有"还没记住地址就上报的"、有坏帧被丢的)------但要能解释每一处差异,这就是"会用统计排查"。
第 9 步:收工。 三个终端依次 Ctrl+C:网关 → 设备面板 → socat(虚拟串口消失)。
1.3 常见错误与排查
| 现象 | 原因 | 解决 |
|---|---|---|
gateway: cannot find -lubox |
libubox 没装好 / 没 ldconfig |
回课时 15 装 libubox,装完 sudo ldconfig |
编译报 uloop_fd_handler 参数不匹配 |
回调签名写错 | 必须是 void cb(struct uloop_fd *f, unsigned int events) |
网关启动报 No such file or directory |
串口不在(socat 没跑) | 先做第 2 步的终端 A;ls -l /tmp/ttyV0 确认 |
网关报 bind: Address already in use |
端口 9200 被上一次的网关占着 | `ss -lunp |
| 上位机发命令,网关没反应 | 网关没跑 / 发到了错的端口 | 看网关有没有打印"上位机地址 = ...";确认端口一致 |
| 网关日志一直在刷"(还没有上位机发过来命令...)" | 还没发过命令 | 随便发一条:python3 -u udp_client.py status |
udp_client.py listen 收不到状态 |
地址被别的程序顶掉了(第 7 步) | 再发一条命令抢回地址;或直接用 status/led on 这类命令(它们自己会收 1.2 秒) |
| Windows 宿主机上发命令收不到回复 | 客户端绑的是 0.0.0.0:9100,网关回给它的虚拟机侧地址 |
用 --gw <虚拟机IP> 发命令;回复会回到宿主机的 9100(防火墙可能拦,先在本机演示) |
| 设备面板没反应但网关日志正常 | 串口方向反了 | 设备面板必须用 /tmp/ttyV1,网关必须用 /tmp/ttyV0 |
| 收到的帧数字对不上统计 | 半帧、坏帧、地址未注册 | 都属于正常损耗,看日志逐条解释 |
二、学生练习
本节命令速查表:
| 命令 | 作用 | 示例 |
|---|---|---|
gcc -Wall -Wextra -o gateway gateway.c -lubox |
编译网关 | 必须加 -lubox |
./gateway /tmp/ttyV0 9200 |
启动网关(串口 + UDP 端口) | 参数可省略,默认就是这个值 |
python3 -u udp_client.py listen |
只听设备状态(UDP) | Ctrl+C 退出 |
python3 -u udp_client.py led on |
通过网络点灯 | led off / fan 2 / status / reset |
python3 -u udp_client.py status --gw 192.168.31.129 |
指定网关地址(宿主机上发命令) | 默认 127.0.0.1 |
| `ss -lunp | grep 9200` | 看谁占着 UDP 9200 |
python3 ../../课时22-串口命令行调试/crc_check.py --crc 01 01 01 |
算一帧的 CRC | 造测试帧用 |
| 编号 | 任务 | 提示 | 预期结果 |
|---|---|---|---|
| L27.1 | 编译 + 三个终端跑起来 | 第 1、2 步 | 网关打印"网关已启动..."和"(还没有上位机发来过命令...)" |
| L27.2 | 用 UDP 点灯 | 第 3 步 | 设备面板灯变绿;网关打印"上位机 → 串口"和"已转给上位机" |
| L27.3 | 只听模式看上报 | 第 4 步 | 每秒一行"设备状态:..." |
| L27.4 | 四条命令都试 | 第 5 步 | 灯/风扇/查状态/复位都生效 |
| L27.5 | 发一帧 CRC 错的命令 | 第 6 步 | 网关打印"上位机帧 CRC 不对...丢弃",设备无反应 |
| L27.6 | 复现"地址被顶掉" | 第 7 步 | 网关把状态转给了一个高位端口,listen 收不到 |
| L27.7 | 停网关看统计 | 第 8 步 | 打印"串口收 x 帧、转给上位机 y 帧..."并解释差异 |
| L27.8 | 换个端口启动两个网关? | 想一下:两个网关能同时用一个串口吗? | 第二个会打开失败/抢数据------串口是独占总线 |
三、作业
- 必做:交 3 张截图(网关启动+设备每秒上报被"暂不转发" / 一条 UDP 命令点亮设备 + 网关的三行转发日志 / 停网关时的统计行),并回答:① 网关为什么要同时管两个 fd?如果只用"一个 while 循环先读串口再读 UDP"会有什么问题?② 为什么本节先用 UDP 而不是 TCP?③ 网关为什么要校验 CRC 后再转发?④ "记住最近的上位机地址"这个设计有什么坑?你会怎么改?
- 选做:① 给网关加一条"日志时间戳"(每行前面打印
HH:MM:SS,方便对照时序);② 试着把网关的上报目标改成固定地址 (比如写死127.0.0.1:9100,用sendto发过去),体会"谁说话听谁的"和"固定目标"的区别 - 提交:截图 + 文字回答,命名
课时27-作业截图.png
四、本节小结与下节预告
本节写出了项目的"中间人":一个 uloop 同时管串口 fd 和 UDP fd ,两边原样转发、切帧、校验 CRC、打印统计。你现在应该能说清这条链路的每一段(网络 → 网关 → 串口 → 设备,以及反向)。同时我们也看到了简化设计的代价:"谁说话听谁"的上报目标很容易被顶掉。
下节课(课时 28)把 UDP 换成/加上 TCP 命令通道 :你会遇到网络编程里最经典的坑------粘包和半包 (TCP 是字节流,两帧可能一起到、一帧可能分两次到),并用"2 字节长度前缀"把它解决掉;顺便复习课时 14/17 的 accept 与多客户端。
附录:怎么运行 + 完整代码
两个文件都在
代码/课时27-网关骨架串口与UDP/:gateway.c(C 网关,本节主角)和udp_client.py(上位机替身/调试工具)。行尾请用 LF。
附录 A|环境准备 + 怎么运行
shell
# ===== ⓿ 环境准备(缺哪个装哪个;完整清单见课时 24 附录 F)=====
sudo apt update
sudo apt install -y build-essential cmake pkg-config socat # gcc + 虚拟串口
sudo apt install -y python3-pyqt5 python3-serial xvfb # 设备面板(课时 26 的程序)
ls /usr/local/include/libubox/uloop.h # 课时 15 装的 libubox
ldconfig -p | grep libubox # 找不到就回课时 15 重装 + sudo ldconfig
# ===== 终端 A:造虚拟串口(保持运行)=====
socat -d -d pty,raw,echo=0,link=/tmp/ttyV0 pty,raw,echo=0,link=/tmp/ttyV1
ls -l /tmp/ttyV0 /tmp/ttyV1
# ===== 终端 B:设备面板(课时 26 的程序;桌面里跑,或用 xvfb)=====
cd ~/lab/课时26-设备面板接串口
python3 device_panel_serial.py
xvfb-run -a python3 device_panel_serial.py --shot /tmp/panel.png --shot-after 20 # 没有桌面时
# ===== 终端 C:网关 =====
cd ~/lab/课时27-网关骨架串口与UDP
gcc -Wall -Wextra -o gateway gateway.c -lubox
./gateway /tmp/ttyV0 9200
# ===== 终端 D:上位机(UDP)=====
cd ~/lab/课时27-网关骨架串口与UDP
python3 -u udp_client.py listen # 只听
python3 -u udp_client.py led on # 点灯
python3 -u udp_client.py fan 2 # 风扇 2 档
python3 -u udp_client.py status # 查状态
python3 -u udp_client.py reset # 复位
附录 B|C 网关 gateway.c(完整文件在代码目录,这里讲关键几段)
B-1 全局状态:两个 fd、串口缓冲、上位机地址、统计
c
static struct uloop_fd ufd_ser; /* 串口"有数据可读"事件 */
static struct uloop_fd ufd_udp; /* UDP "有数据报可读"事件 */
static uint8_t rxbuf[512]; /* 串口缓冲区(字节流,要自己拼帧) */
static int rxlen;
static struct sockaddr_in peer; /* 最近给网关发过命令的"上位机"地址 */
static int has_peer;
static int ser_tx, ser_rx, udp_tx, udp_rx; /* 统计,方便看现象 */
B-2 原样转发:把设备状态发给"最近发过命令的上位机"
c
/* 把一帧发给"最近给网关发过命令的那个上位机" */
static void forward_to_peer(const uint8_t *frame, int len)
{
if (!has_peer) {
printf("(还没有上位机发来过命令,这一帧先不转发)\n");
fflush(stdout);
return;
}
if (sendto(udp_fd, frame, (size_t)len, 0,
(struct sockaddr *)&peer, sizeof(peer)) != len) {
perror("sendto");
return;
}
udp_tx++;
printf("已转给上位机 %s:%d\n", inet_ntoa(peer.sin_addr), ntohs(peer.sin_port));
fflush(stdout);
}
B-3 串口侧:切帧 + CRC 校验 + 转发
c
/* 串口缓冲区里切帧:找帧头 → 判长度 → 取一帧 → 校验 CRC → 转给上位机 */
static void serial_handle_frames(void)
{
while (rxlen >= 4) {
int total;
if (!(rxbuf[0] == FRAME_HEAD0 && rxbuf[1] == FRAME_HEAD1)) {
memmove(rxbuf, rxbuf + 1, (size_t)(--rxlen)); /* 丢一个字节,重新找帧头 */
continue;
}
if (rxbuf[3] > MAX_FRAME) {
memmove(rxbuf, rxbuf + 1, (size_t)(--rxlen)); /* 长度不合理,也丢掉重找 */
continue;
}
total = 2 + 2 + rxbuf[3] + 2;
if (rxlen < total) {
printf("收到半帧(%d/%d 字节),等后面补上...\n", rxlen, total);
fflush(stdout);
return; /* 半帧:等下一次读 */
}
{
uint16_t calc = crc16_modbus(rxbuf + 2, 2 + rxbuf[3]);
uint16_t got = (uint16_t)(rxbuf[4 + rxbuf[3]] | (rxbuf[5 + rxbuf[3]] << 8));
if (calc != got) {
printf("CRC 校验失败(算出 %04X,帧里 %04X),丢弃\n", calc, got);
fflush(stdout);
} else {
ser_rx++;
dump("串口 → 上位机", rxbuf, total);
forward_to_peer(rxbuf, total);
}
}
rxlen -= total;
memmove(rxbuf, rxbuf + total, (size_t)rxlen);
}
}
/* 串口有数据可读:uloop 回调 */
static void on_serial_readable(struct uloop_fd *f, unsigned int events)
{
ssize_t n;
(void)events;
n = read(f->fd, rxbuf + rxlen, sizeof(rxbuf) - (size_t)rxlen);
if (n < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) {
return;
}
perror("read(serial)");
uloop_end();
return;
}
if (n == 0) {
return;
}
rxlen += (int)n;
serial_handle_frames();
}
B-4 UDP 侧:收命令 → 校验 → 写串口;以及 main 里怎么把两个 fd 都加进 uloop
c
/* UDP 有数据报可读:uloop 回调(上位机的命令从这里进来) */
static void on_udp_readable(struct uloop_fd *f, unsigned int events)
{
uint8_t buf[MAX_FRAME + 16];
struct sockaddr_in src;
socklen_t slen = sizeof(src);
ssize_t n;
int len;
uint16_t calc, got;
(void)events;
n = recvfrom(f->fd, buf, sizeof(buf), 0, (struct sockaddr *)&src, &slen);
if (n <= 0) {
return;
}
peer = src; /* 记住它:设备上报就发回这里 */
has_peer = 1;
if (n < 6 || buf[0] != FRAME_HEAD0 || buf[1] != FRAME_HEAD1) {
printf("上位机来的不是合法帧(%d 字节),丢弃\n", (int)n);
fflush(stdout);
return;
}
len = buf[3];
if (n != 6 + len) {
printf("上位机帧长度不对(收到 %d,帧里写着 %d),丢弃\n", (int)n, 6 + len);
fflush(stdout);
return;
}
calc = crc16_modbus(buf + 2, 2 + len);
got = (uint16_t)(buf[4 + len] | (buf[5 + len] << 8));
if (calc != got) {
printf("上位机帧 CRC 不对(算出 %04X,帧里 %04X),丢弃\n", calc, got);
fflush(stdout);
return;
}
udp_rx++;
printf("上位机地址 = %s:%d\n", inet_ntoa(src.sin_addr), ntohs(src.sin_port));
dump("上位机 → 串口", buf, (int)n);
if (write(ser_fd, buf, (size_t)n) != n) {
perror("write(serial)");
return;
}
ser_tx++;
}
int main(int argc, char *argv[])
{
const char *dev = (argc >= 2) ? argv[1] : DEV_DEFAULT;
int port = (argc >= 3) ? atoi(argv[2]) : UDP_PORT;
uloop_init();
ser_fd = serial_open(dev);
if (ser_fd < 0) { uloop_done(); return 1; }
udp_fd = udp_open(port);
if (udp_fd < 0) { close(ser_fd); uloop_done(); return 1; }
ufd_ser.fd = ser_fd;
ufd_ser.cb = on_serial_readable;
uloop_fd_add(&ufd_ser, ULOOP_READ);
ufd_udp.fd = udp_fd;
ufd_udp.cb = on_udp_readable;
uloop_fd_add(&ufd_udp, ULOOP_READ);
printf("网关已启动:串口 %s @ 9600 ⇄ UDP 0.0.0.0:%d(Ctrl+C 退出)\n", dev, port);
fflush(stdout);
uloop_run(); /* 之后就是"谁有事谁回调" */
uloop_done();
printf("\n网关退出。统计:串口收 %d 帧、转给上位机 %d 帧;上位机收 %d 帧、转给串口 %d 帧\n",
ser_rx, udp_tx, udp_rx, ser_tx);
close(ser_fd);
close(udp_fd);
return 0;
}
附录 C|上位机替身 udp_client.py(完整文件在代码目录)
它只做三件事:组帧 (build_frame)、发出去 (sendto)、把收到的帧翻译成人话 (explain)。
关键片段(和课时 26 的 serial_monitor.py 是同一套 CRC 与翻译逻辑,只是把串口换成了 UDP):
python
def build_frame(type_: int, payload: bytes = b"") -> bytes:
body = bytes([type_, len(payload)]) + payload
crc = crc16_modbus(body)
return b"\x55\xaa" + body + bytes([crc & 0xFF, crc >> 8])
def send_command(host, port, frame):
s = make_socket() # 绑定本机 9100,便于网关把状态发回来
s.sendto(frame, (host, port))
print("发送到 %s:%d:%s" % (host, port, frame_text(frame)))
end = time.time() + 1.2 # 发完等 1.2 秒,把回复也打印出来
while time.time() < end:
try:
data, addr = s.recvfrom(2048)
except socket.timeout:
continue
print("收到 %s → %s" % (frame_text(data), explain(data)))
s.close()
用法回顾:listen(只听)/ led on|off / fan 0~3 / status / reset,
还可以加 --gw 192.168.31.129 --port 9200 指定网关(上位机跑在 Windows 上时用)。