Air780E 多卡短信中枢(一):从需求边界到 Agent / Server 架构

最后更新:2026-08-04
所属系列:Air780E 多卡短信中枢实战 · 第 1 / 6 篇
文章目录 10 个章节

我手里有多个 Air780E USB 模块和多张主要用于收验证码、偶尔需要保号的 SIM 卡。最初的问题很朴素:模块插在本地 Linux 主机上,我希望在外面也能统一查看短信、回复短信、接收推送,并让保号任务在断网时仍然照常执行。

真正开始做以后,这件事很快从“读一下串口”变成了一个完整系统:硬件发现、AT 协议、PDU 编解码、本地持久化、断网补传、中心服务、通知规则、Web 管理、部署与安全,一个都绕不过去。

这个系列记录 air780e-hub 从第一版设计到双模块实测、再到公开发布的完整过程。第一篇先讲最重要的部分:问题到底是什么,以及架构为什么最后长成现在这样。

一、先把目标写清楚

这个项目真正要解决的是五件事:

  1. 多个 Air780E、多个 SIM 同时在线,数据不能串卡;
  2. 中文、Unicode 和长短信能可靠收发;
  3. 收到短信后按规则推到 Bark、Telegram、飞书等渠道;
  4. 保号任务可以配置,而且不能因为中心服务器暂时断线就停摆;
  5. 所有状态都能在一个 Web 后台查看和操作。

同时,我也明确排除了一批“看起来相关、实际上会把项目做散”的功能:

  • 不做 eSIM / lpac;
  • 不做频段锁定和小区锁定;
  • 不把 Air780E 当上网卡;
  • 不做 DDNS、WLAN 和 OTA 管理;
  • 不做多租户和复杂 RBAC。

这些边界非常重要。硬件项目很容易陷入“模块支持什么就做什么”,最后做成一个功能很多、核心链路却不可靠的控制面板。我的目标一直是短信,不是通用 CPE 管理系统。

二、为什么没有直接 fork SimAdmin

调研阶段重点看了几个项目:

最开始最像答案的是 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 推进,但顺序不是先写页面,而是先建立可验证闭环:

  1. M0:假模块、AT 驱动、PDU 编解码
  2. M1:Agent、本地队列、断网重放
  3. M2:Server、协议、认证和 Docker
  4. M3:能看、能发的 Web 界面
  5. M4:通知规则与多渠道推送
  6. M5:本地保号调度器
  7. M6:真实双模块接入
  8. 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 项自动化测试通过。

但真正有价值的不是功能数量,而是几个从第一天就明确的原则:

  1. 先写“不做什么”,防止范围失控;
  2. 数据跟业务身份走,不跟临时硬件位置走;
  3. 让靠近事实的一侧持有真相:Agent 持有硬件事件,Server 持有管理配置;
  4. 网络不可靠是正常状态,不是异常分支;
  5. 硬件到货前,用模拟器把软件闭环跑通。

下一篇进入最有现场感的部分:三个 ttyACM 口、重复 USB 序列号、只有 10 条的短信存储,以及一次拔掉模块后系统仍显示在线的组合故障。