我手里有多个 Air780E USB 模块和多张主要用于收验证码、偶尔需要保号的 SIM 卡。最初的问题很朴素:模块插在本地 Linux 主机上,我希望在外面也能统一查看短信、回复短信、接收推送,并让保号任务在断网时仍然照常执行。
真正开始做以后,这件事很快从“读一下串口”变成了一个完整系统:硬件发现、AT 协议、PDU 编解码、本地持久化、断网补传、中心服务、通知规则、Web 管理、部署与安全,一个都绕不过去。
这个系列记录 air780e-hub 从第一版设计到双模块实测、再到公开发布的完整过程。第一篇先讲最重要的部分:问题到底是什么,以及架构为什么最后长成现在这样。
一、先把目标写清楚
这个项目真正要解决的是五件事:
- 多个 Air780E、多个 SIM 同时在线,数据不能串卡;
- 中文、Unicode 和长短信能可靠收发;
- 收到短信后按规则推到 Bark、Telegram、飞书等渠道;
- 保号任务可以配置,而且不能因为中心服务器暂时断线就停摆;
- 所有状态都能在一个 Web 后台查看和操作。
同时,我也明确排除了一批“看起来相关、实际上会把项目做散”的功能:
- 不做 eSIM / lpac;
- 不做频段锁定和小区锁定;
- 不把 Air780E 当上网卡;
- 不做 DDNS、WLAN 和 OTA 管理;
- 不做多租户和复杂 RBAC。
这些边界非常重要。硬件项目很容易陷入“模块支持什么就做什么”,最后做成一个功能很多、核心链路却不可靠的控制面板。我的目标一直是短信,不是通用 CPE 管理系统。
二、为什么没有直接 fork SimAdmin
调研阶段重点看了几个项目:
- SimAdmin:Web 管理功能非常完整;
- chenxuuu/sms_forwarding:短信转发和保号的产品思路成熟;
- soxfmr/linux-air780e:Linux 下直接驱动 Air780E 的经验很有价值;
- 合宙 Air780E AT 指令文档:硬件协议的主要依据。
最开始最像答案的是 SimAdmin,但深入看完以后决定只参考功能设计,不 fork 代码。
原因不是“自己写更酷”,而是底层假设完全不同:
- SimAdmin 面向 Debian 蜂窝 CPE,核心依赖 ModemManager、D-Bus 和部分 QMI 能力;
- 我的模块运行 EC618 AT 固件,主要通过
/dev/ttyACM*直接交互; - SimAdmin 更接近“设备自己管理自己”,而我的模块主机在内网,中心服务在另一台机器;
- 最关键的是多卡模型:短信、规则和任务必须跟 SIM 走,不能只跟某个 modem 槽位走。
如果强行 fork,表面上省了 Web 页面,实际上要重写 modem 层、短信监听、设备发现、数据模型和部署方式。保留下来的只会是目录结构和历史包袱。
这次得到的第一条经验是:
评估是否 fork,不能只看功能截图和技术栈,要看对方的数据模型、硬件抽象和部署边界是否与自己的问题一致。
三、最终确定的三层结构
系统最后拆成三层:
Air780E × N
│ USB / AT
▼
Agent(模块所在的 Linux 主机)
├── 串口发现与独立 Worker
├── PDU / AT 驱动
├── 本地 SQLite 事件队列
└── 本地保号调度器
│
│ 主动出站 WSS
▼
Server(Docker / Python)
├── WebSocket 网关
├── REST API
├── 中心 SQLite
├── 通知规则与推送引擎
└── React 管理界面
Agent 为什么必须存在
串口只能在连接模块的主机上访问,而且 AT 命令是有状态、半双工的。把硬件细节全部留在 Agent,可以让 Server 只处理稳定的 JSON 事件,不需要知道 /dev/ttyACM3、URC 或 PDU 是什么。
Agent 还是数据可靠性的第一道防线:
- 短信先落本地 SQLite;
- 上行事件持久化以后再发送;
- Server 确认前不删除;
- 断线后继续收短信和执行保号任务;
- 恢复连接后按顺序补传。
为什么连接方向是 Agent → Server
模块主机通常在家庭网络、CGNAT 或动态 IP 后面。如果由 Server 主动连接 Agent,就要额外解决公网 IP、端口映射、DDNS 和入站防火墙,还会把能读取验证码的接口直接暴露出去。
改成 Agent 主动拨出 WSS 后,网络边界简单很多:
- Agent 只需要访问一个
wss://地址; - 本地不监听公网端口;
- 同一条连接既能上报事件,也能接收发送短信、查询状态等命令;
- TLS 统一交给 Server 前面的可信反向代理。
为什么保号调度器在 Agent
通知推送放在 Server,因为它可以使用普通网络,不消耗 SIM 流量;保号调度却必须放在 Agent,因为计划一旦错过就无法补救。
Server 负责编辑和下发任务,Agent 负责持久化和执行。这样中心网络中断时,短信、Ping 或自定义 AT 任务仍能按本地时钟运行,结果先进入队列,联网后再回传。
四、最关键的数据建模:卡不是槽位
早期最容易犯的错误,是把短信全部挂到 device_id 上。这样看起来直观:模块 A 收到的短信就属于模块 A。
但实际身份应该是 SIM:
- 模块可能换 USB 口;
ttyACM编号会变化;- SIM 可能从模块 A 换到模块 B;
- 用户关心的是“哪张卡收到的”,而不是“哪个 USB 设备收到的”。
因此 Server 中的核心关系是:
devices ── 当前承载 ──> sims
├── messages
├── rules
└── tasks
短信、转发规则和保号任务都归属 sim_id。模块只描述当前硬件位置。换卡或换模块以后,历史仍跟着 ICCID 对应的 SIM,不会出现同一张卡的记录被拆成两份。
这条模型一旦确定,后面的会话列表、通知规则、任务路由和备份恢复都顺了。反过来,如果底层先按设备写死,前端再怎么补标签都只是遮住错误模型。
五、开发顺序:硬件到货前先消灭软件不确定性
项目按 M0 到 M7 推进,但顺序不是先写页面,而是先建立可验证闭环:
- M0:假模块、AT 驱动、PDU 编解码;
- M1:Agent、本地队列、断网重放;
- M2:Server、协议、认证和 Docker;
- M3:能看、能发的 Web 界面;
- M4:通知规则与多渠道推送;
- M5:本地保号调度器;
- M6:真实双模块接入;
- M7:监控、会话体验、告警、备份恢复和响应式界面。
M0 到 M5 都不依赖真机。为此项目实现了一个假 Air780E:它能响应 AT 指令、产生短信 URC、模拟存储容量,甚至会在存储满时像真实模块一样丢掉新短信。
这让硬件到手时,验证目标非常明确:不是“边插设备边猜程序怎么写”,而是检查真实模块在哪些地方偏离了模拟器和手册。
六、技术选型为什么都很普通
- Agent:Python、pyserial、SQLite;
- Server:Python、FastAPI、SQLite;
- Frontend:React、Vite、MUI;
- 传输:JSON over WebSocket;
- 部署:Docker Compose + systemd;
- Python 包管理:uv。
没有消息队列、微服务、PostgreSQL 集群或插件框架。这个系统的真实规模是几个模块、每分钟少量事件。SQLite 单文件既方便备份,也让 Agent 和 Server 的故障面保持很小。
“以后可能扩展”不是现在引入复杂度的理由。真正需要预留的是边界:Agent 与 Server 之间使用明确协议,因此未来即使把 Agent 重写成 Go,也不必改硬件之外的部分。
七、第一阶段的结果
最终系统完成了双模块实测,覆盖中文与长短信收发、通知、保号、断网补传、信号监控、会话视图、全文检索、CSV 导出、TTL 清理和 SQLite 备份恢复。Agent 与 Server 共 278 项自动化测试通过。
但真正有价值的不是功能数量,而是几个从第一天就明确的原则:
- 先写“不做什么”,防止范围失控;
- 数据跟业务身份走,不跟临时硬件位置走;
- 让靠近事实的一侧持有真相:Agent 持有硬件事件,Server 持有管理配置;
- 网络不可靠是正常状态,不是异常分支;
- 硬件到货前,用模拟器把软件闭环跑通。
下一篇进入最有现场感的部分:三个 ttyACM 口、重复 USB 序列号、只有 10 条的短信存储,以及一次拔掉模块后系统仍显示在线的组合故障。