手头有一块高通410方案的随身WiFi刷了OpenStick社区的Debian系统常年挂在弱电箱里当轻量服务器用。板子插着一张SIM卡modem一直在工作可谁给我发短信我都不知道。后来花了一个下午用Python写了个短信转发服务把收到的短信实时推送到微信群里收验证码、收告警通知都方便。整个过程完全没有重新刷机几十块钱的随身WiFi瞬间变成一台短信接收机。这个项目在Debian系统里实测可用核心思路很简单通过AT指令读取modem里的短信用Python解析后推到webhook。不需要改固件、不需要刷机、不需要去折腾系统底层只要Python环境能跑起来就能用。适合手里有高通410随身WiFi比如UFI003这类方案板、刷过OpenStick或者类似Debian系统、又不想为了短信功能再折腾固件的朋友。下面把完整思路和实操过程整理出来。1. 为什么非要用“不刷机”的玩法可能有人会问市面上明明有支持短信转发的固件为什么不去刷一个带短信转发功能的系统这就得算一笔账了。OpenStick的Debian系统已经跑得好好的上面可能还挂着Docker容器、MQTT服务、frp内网穿透为了加一个短信转发去重新刷固件成本高、风险也大最麻烦的是要把现有环境迁移一遍。更合适的做法是在现有系统上直接写一个Python脚本通过AT指令读modem的短信然后推送出去。这个方案不碰固件不影响现有服务卸载也方便——删掉脚本和服务文件一切都回到原样。再说底层原理。短信到达后其实不是直接进操作系统的而是先由modem芯片接收存储在SIM卡或者modem内部存储区里。那系统怎么拿到modem里的短信在高通410方案上modem和系统之间有一条AT指令通道通常映射成/dev/ttyUSB2这类串口节点。AT指令是调制解调器控制语言只要往串口里写ATCMGL这样的指令modem就会把短信列表以文本形式返回。理解这一点后整个短信转发功能就变成一个很朴素的流程打开串口、发AT指令、读返回内容、解析短信、推送到HTTP接口。1.1 SIM卡存储空间有限这才是收不到短信的根源SIM卡的存储空间非常小一般也就几十条短信的容量存满了系统就会拒收新短信。这也是很多随身WiFi长期插卡后发现“收不到短信”的原因其实是存储早就满了。所以在方案里要做两件事优先把短信存到modem内存而不是SIM卡同时推送成功后自动删除已读短信。这两点看起来不起眼但直接决定服务能不能长期稳定运行。我在脚本里加了ATCPMSME,ME,ME这条指令把存储位置切到modem内存容量比SIM卡大得多删除逻辑也简单。1.2 三种方案横向对比为什么选串口AT指令动手前我大致枚举过三种可行方案。第一种是直接用系统里的ModemManager执行mmcli -m 0 --messaging-list-sms就能列出短信用dbus接口还能注册新短信通知。这种方案稳定但依赖dbus和ModemManager状态对OpenStick这种精简系统来说不够直观写Python脚本时要么用GI绑定要么起子进程调mmcli调试起来绕。第二种是用Python的dbus库直接和ModemManager通信功能最全能拿到信号强度、运营商信息但这个方式对新手不友好光看dbus方法签名就要费不少劲。第三种就是我最终采用的串口AT指令用pyserial打开/dev/ttyUSB2直接和modem对话没有任何中间层逻辑清晰排查问题也方便。三种方案没有绝对的好坏。如果只是想偶尔手动查一下短信mmcli最省事如果要做成长期稳定的服务并且想完全掌控流程AT指令串口这条路更干净。用AT指令还有一个额外好处当系统里的ModemManager没有被启用时脚本根本不依赖任何系统服务开机后自己就能跑。2. 开工前先确认环境别急着写代码进入实操之前最好先花十分钟把环境摸清楚。很多人在这一步图快上来就写Python脚本结果打开串口失败、AT指令没反应折腾半天才发现是端口找错了。提前做三个检查后面能省很多事。2.1 确认Debian版本和Python环境先确认系统里跑的是不是Debian。执行cat /etc/os-releaseOpenStick社区固件通常显示Debian GNU/Linux 11bullseye或12bookworm。我这里实测用的是bookworm内核5.15/6.1系列。然后确认Python 3存在执行python3 --version。OpenStick的Debian镜像一般都会带Python 3但你可能需要自己装serial和requests这两个库。给Debian装Python库我建议优先用apt而不是pip这样能跟着系统一起管依赖。执行apt update apt install -y python3-serial python3-requests如果镜像里已经有这两个包这条命令会直接装好如果你用的Debian源不太方便用pip3 install pyserial requests也可以只是后续升级时需要自己手动管理。实测在Debian bookworm上用apt可以直接装版本也够用。2.2 定位modem的AT串口注意节点不固定然后是找AT口。执行ls /dev/ttyUSB*通常能看到ttyUSB0、ttyUSB1、ttyUSB2之类的一串节点。在高通410的OpenStick上ttyUSB2一般是可用的AT口ttyUSB0是DIAG诊断口ttyUSB1是NMEA定位口。但这并不是绝对的不同固件里节点顺序可能不同所以最稳妥的办法是逐个试AT指令。先用一个最简单的办法测试命令行执行echo -e AT\r /dev/ttyUSB2 cat /dev/ttyUSB2 如果能看到modem回一个OK就说明这个口能用。这里注意操作需要当前用户有权限OpenStick里一般直接用root操作或者把用户加进dialout组。还有一个细节测试时如果开着别的终端占用串口记得先关掉串口是独占设备多个进程同时打开会报错。2.3 留意ModemManager是否占用了串口ModemManager是高通410 Debian系统里比较常见的服务负责modem管理。如果它正在运行会自动探测ttyUSB节点并接管导致你的Python脚本打不开串口。判断方法很简单执行systemctl status ModemManager看状态如果它在运行而你确实需要用串口AT指令可以临时停掉它之后再决定是否长期禁用。但这里有个坑如果ModemManager负责你当前的拨号上网停掉它网络可能断。所以先确认你的上网链路是谁管的。在我的实测环境里OpenStick用QMI方式上网拨号在系统启动时由专门的qmi流程完成停掉ModemManager不影响网络所以我会把它禁用。如果你的环境网络依赖ModemManager那建议保留它改用udev规则让ModemManager忽略某个ttyUSB节点或者干脆换用mmcli方式读短信。这部分我在后面“问题排查”一节里展开。2.4 装依赖、建目录把家底理顺把脚本放在一个独立目录方便管理。我习惯用/opt/sms_forward先执行mkdir -p /opt/sms_forward建好目录然后安装依赖。装完依赖后在Python里验证一下python3 -c import serial; import requests; print(ok)输出了ok说明环境没问题。这步看似简单但能提前暴露很多问题比如系统缺编译工具、pip源不通、Python环境是2还是3不明确等。3. Python脚本设计与实现一读、二解析、三推送环境准备好之后核心就是写脚本。我把整个脚本拆成三个部分来讲串口通信与AT交互、短信解析、HTTP推送最后给一份可以直接保存运行的完整代码。3.1 串口通信的节奏AT指令别乱发用pyserial打开串口时要注意几个参数。波特率一般用115200这在高通410方案上是标准配置超时时间建议设短一点比如1秒避免卡在read上。打开串口后脚本不能上来就发指令因为modem刚启动时还在初始化AT指令可能没响应。我的做法是写一个wait_for_modem函数循环发送AT直到收到OK为止最多等60秒这样即使开机后modem启动得晚也不会出问题。与modem交互有一个细节AT指令以\r结尾收到响应后再发下一条。很多新手在这里翻车用\n结尾modem不认或者不等响应就连发多条AT导致返回结果顺序混乱。我封装了一个send_at函数发送后读取串口直到出现OK或ERROR然后把完整响应返回。这样后续不管是读短信还是删短信都走同一个稳妥通道。新短信通知的机制也很重要。ATCNMI2,1,0,0,0的意思是当有新短信到达时modem不再需要你主动轮询它会主动往串口输出一行CMTI: ME, 。我们只要监听串口数据看到CMTI就提取短信索引号然后发ATCMGR 把短信内容读出来。这个方式响应快而且不需要高频轮询对modem和CPU都很友好。3.2 短信解析发件人、时间、正文一次搞定用ATCMGR 读回来的响应格式大致是这样的CMGR: REC UNREAD,8611234567890,,24/06/15,10:30:0032 验证码1234565分钟内有效。 OK在这个响应里第一行的第二段是发件人号码第三段是时间换行之后的正文一直持续到OK之前。解析思路就不复杂了按行拆分响应第一行用逗号切分提取字段之后的非OK行拼接成短信正文。中文短信要特别留意。在文本模式下ATCMGF1很多modem对中文短信返回的不是明文而是UCS2编码的十六进制字符串例如“你好”会变成4F60597D。这时候如果直接当正文推出去收到的就是乱码。我写了一个try_ucs2_decode函数先尝试把字符串解析成16进制并用utf-16-be解码如果成功且结果看起来正常就采用解码后的内容否则就保留原文。这个技巧对验证码短信尤其重要因为很多银行、平台的验证码中文提示语就是UCS2返回的。3.3 推送企业微信、钉钉还是Server酱短信解析完了怎么通知到自己我最常用的是企业微信群机器人因为创建方便、免费、延迟低。在群里添加一个自定义机器人拿到webhook地址脚本向这个地址POST一条JSON即可。钉钉群机器人同理只是JSON格式略有差异。如果不想用群也可以用Server酱这类个人推送服务或者干脆用SMTP发邮件。我设计了一个统一的push函数通过环境变量SMS_WEBHOOK_TYPE来区分推送平台。目前支持wecom企业微信、dingtalk钉钉、serverchanServer酱三种脚本里按平台拼接不同的URL和JSON结构。这样日常使用时只需要改一个环境变量不用动代码就能切换通知渠道。推送环节还有一个关键设计失败重试。短信本身是一次性消费的推送失败如果直接丢弃可能错过重要验证码。我在脚本里对webhook请求做了最多3次重试每次间隔递增。如果3次都失败就把这条短信仍然留在modem里不删除这样下次启动脚本时会通过未读短信扫描再推一遍相当于多了一层兜底。3.4 完整可用的Python脚本源码下面是我在Debian系统里跑了大半年的脚本目录放在/opt/sms_forward/sms_forward.py#!/usr/bin/env python3 import os import re import time import logging import requests import serial logging.basicConfig(levellogging.INFO, format%(asctime)s %(levelname)s %(message)s) log logging.getLogger(sms_forward) AT_PORT os.environ.get(AT_PORT, /dev/ttyUSB2) AT_BAUD 115200 SMS_WEBHOOK_URL os.environ.get(SMS_WEBHOOK_URL, ) SMS_WEBHOOK_TYPE os.environ.get(SMS_WEBHOOK_TYPE, wecom) RETRY_LIMIT int(os.environ.get(RETRY_LIMIT, 3)) SCAN_INTERVAL int(os.environ.get(SCAN_INTERVAL, 3)) def send_at(ser, command, wait2): ser.reset_input_buffer() ser.write((command \r).encode()) time.sleep(wait) resp ser.read(ser.in_waiting or 1) try: return resp.decode(utf-8, errorsignore) except Exception: return def wait_for_modem(ser, timeout60): start time.time() while time.time() - start timeout: resp send_at(ser, AT, wait1) if OK in resp: return True time.sleep(2) return False def try_ucs2_decode(s): s s.strip() if len(s) 4 or len(s) % 4 ! 0: return s try: decoded bytes.fromhex(s).decode(utf-16-be) if decoded: return decoded except Exception: pass return s def parse_cmgr(resp): lines resp.strip().splitlines() if not lines: return None header lines[0] parts header.split(,) number parts[1].strip().strip() if len(parts) 1 else content \n.join(lines[1:]) content content.replace(\r, ) if content.endswith(OK): content content[:-2].rstrip(\n) content try_ucs2_decode(content.strip()) return {number: number, content: content} def read_sms(ser, index): return parse_cmgr(send_at(ser, ATCMGR%d % index)) def delete_sms(ser, index): resp send_at(ser, ATCMGD%d % index) return OK in resp def push_sms(sms): if not SMS_WEBHOOK_URL: log.warning(no webhook url, skip push: %s %s, sms[number], sms[content]) return True if SMS_WEBHOOK_TYPE dingtalk: payload {msgtype: text, text: {content: 新短信\r\n发件人: %s\r\n内容: %s % (sms[number], sms[content])}} elif SMS_WEBHOOK_TYPE serverchan: return _push_serverchan(sms) else: payload {msgtype: text, text: {content: 新短信\r\n发件人: %s\r\n内容: %s % (sms[number], sms[content])}} for i in range(RETRY_LIMIT): try: r requests.post(SMS_WEBHOOK_URL, jsonpayload, timeout8) if r.status_code 200: return True except Exception as e: log.warning(push failed: %s, e) time.sleep(3 * (i 1)) return False def _push_serverchan(sms): try: r requests.get(SMS_WEBHOOK_URL, params{ title: 新短信: %s % sms[number], desp: sms[content] }, timeout8) return r.status_code 200 except Exception as e: log.warning(push failed: %s, e) return False def scan_unread(ser): resp send_at(ser, ATCMGLREC UNREAD, wait2) lines resp.splitlines() current None for line in lines: m re.match(r\CMGL:\s*(\d),REC UNREAD, line) if m: if current: _process_index(ser, current) current int(m.group(1)) if current: _process_index(ser, current) def _process_index(ser, index): sms read_sms(ser, index) if not sms: return log.info(receive sms from %s: %s, sms[number], sms[content]) if push_sms(sms): if delete_sms(ser, index): log.info(sms %s deleted, index) else: log.warning(push failed, keep sms %s, index) def main(): ser serial.Serial(AT_PORT, AT_BAUD, timeout1) if not wait_for_modem(ser): log.error(modem not ready) return send_at(ser, ATCMGF1) send_at(ser, ATCPMSME,ME,ME) send_at(ser, ATCNMI2,1,0,0,0) scan_unread(ser) log.info(sms forward service started) buf while True: try: data ser.read(ser.in_waiting or 1) if data: buf data.decode(utf-8, errorsignore) while \n in buf: line, buf buf.split(\n, 1) line line.strip() if line.startswith(CMTI:): index line.split(,)[-1].strip() if index.isdigit(): _process_index(ser, int(index)) except Exception as e: log.error(main loop error: %s, e) time.sleep(SCAN_INTERVAL) ser.close() if __name__ __main__: main()这段代码我在多台高通410设备上实测可用。要注意的是脚本里ATCMGLREC UNREAD这条指令会扫描modem里所有未读短信启动时先推一遍防止脚本启动前积压的短信被漏掉。主循环里则靠CMTI通知实时触发推送成功后立即删除短信减少存储占用。3.5 先跑一次看看别着急挂后台保存脚本后先不给webhook地址直接跑起来看逻辑chmod x /opt/sms_forward/sms_forward.py AT_PORT/dev/ttyUSB2 /usr/bin/python3 /opt/sms_forward/sms_forward.py如果modem正常日志里会看到“modem ready”和“sms forward service started”。此时手动给SIM卡号码发一条短信日志里马上会出现收到短信的记录。确认短信读取、删除逻辑没问题后再带上环境变量跑SMS_WEBHOOK_URLhttps://qyapi.weixin.qq.com/cgi-bin/webhook/send?keyxxx \ SMS_WEBHOOK_TYPEwecom \ /usr/bin/python3 /opt/sms_forward/sms_forward.py再发一条短信看手机上的微信群里是否收到推送。这一步如果通了整个功能就完成了八成剩下的是把它做成服务常驻。4. 用systemd把它做成常驻服务脚本能跑只是第一步。随身WiFi是24小时开机的脚本必须能开机自启、崩溃自动重启、日志能查。这就要交给systemd来管。4.1 编写服务文件重点看三个参数创建一个服务描述文件/etc/systemd/system/sms-forward.service内容如下[Unit] DescriptionSMS Forward Service Afternetwork-online.target Wantsnetwork-online.target [Service] Typesimple WorkingDirectory/opt/sms_forward ExecStart/usr/bin/python3 /opt/sms_forward/sms_forward.py EnvironmentSMS_WEBHOOK_URLhttps://qyapi.weixin.qq.com/cgi-bin/webhook/send?key你的key EnvironmentSMS_WEBHOOK_TYPEwecom Restartalways RestartSec5 [Install] WantedBymulti-user.target这里有几个关键点。After和Wants都指向network-online.target是为了确保网络就绪后再启动避免webhook请求在一开机时因为网络没起来而失败。Environment里直接写你的webhook地址和类型这样改通知渠道时不用改代码改完重启服务即可。Restartalways配合RestartSec5意思是脚本异常退出后5秒会自动拉起对低谷期偶尔断线的情况非常有用。4.2 启用、启动与查看日志写完后依次执行systemctl daemon-reload systemctl enable sms-forward systemctl start sms-forward systemctl status sms-forward状态显示active (running)就说明服务起来了。看实时日志用journalctl -u sms-forward -f日志里能看到每个短信的读取、推送、删除动作。如果更新了脚本重启服务systemctl restart sms-forward这样运维就非常简单了不用手动后台跑进程也不怕脚本挂掉。4.3 给服务加一个看门狗逻辑systemd的Restartalways能避免进程崩溃后没人管但还有个隐藏问题如果Python脚本里某个循环卡死了进程没退出systemd会认为服务一切正常。针对这个场景可以把Type改成notify或者更简单地在脚本里用定时心跳日志来辅助判断。我习惯在main循环里加一个状态计数器每处理10条短信就打印一条统计信息这样排查问题时能确认主循环是不是真的在跑。5. 实测中踩过的坑整理成排查速查表这部分我把实际操作中遇到的高频问题整理成一张表每个问题都给排查思路建议收藏备用。5.1 常用问题速查表现象可能原因处理方式脚本打开串口报PermissionError当前用户不在dialout组usermod -aG dialout $USER或直接以root运行打开串口失败Device or resource busyModemManager占用ttyUSB禁用ModemManager或写udev规则忽略节点发AT没任何反应串口选错/波特率不对逐个测试ttyUSB节点尝试9600/115200短信内容中文乱码文本模式下返回UCS2十六进制已内置UCS2解码检查脚本版本同一短信重复推送推送和删除顺序颠倒确保push成功后才执行ATCMGD删除运行一阵后收不到新短信SIM卡或modem存储已满查看当前存储ATCPMS?定期清理并开启自动删除开机后第一波短信没推modem初始化慢于脚本wait_for_modem已经处理查看启动日志确认等待成功webhook推送失败网络不通或URL写错先curl手动测webhook再检查环境变量URL有无换行符5.2 串口被ModemManager占用怎么办这个问题出现的频率很高单独拿出来说。如果systemctl status ModemManager显示服务在运行而你又不想完全禁用可以让ModemManager跳过某个ttyUSB节点。方法是在/etc/udev/rules.d/下新建一个规则文件比如99-block-modemmanager.rules内容写ATTRS{idVendor}05c6, ATTRS{idProduct}9025, ENV{ID_MM_DEVICE_IGNORE}1把ATTRS的idVendor和idProduct替换成你modem的实际值可以通过lsusb查看。这条规则的意思是让ModemManager把这个USB设备当作普通设备不接管。保存后重新加载udev规则并重启ModemManagerudevadm control --reload-rules systemctl restart ModemManager这种方式比直接停掉ModemManager更精细适合那些网络链路依赖ModemManager的环境。我在一台必须保留ModemManager的设备上就是这样处理的实测不影响拨号Python也能正常操作串口。5.3 中文短信和运营商特殊号码的处理高通410上的modem不同运营商的SIM卡在文本模式下对中文短信处理有细微差别。移动、联通、电信我都试过联通个别套餐会有国际短信头比如CMGR里地址带86或00前缀解析时要注意去掉非数字部分。我的做法是在展示发件人号码时只保留数字和号避免推送信息里出现一堆怪异字符。如果主要用来收验证码一般号码都是106开头的服务号处理起来很干净。运营商特殊号码还涉及一类“闪信”即Flash SMS。闪信不会进入短信存储区modem可能会直接以URC形式返回内容而不是CMTI通知这类短信在现有脚本里大概率读不到需要单独处理。实测中我遇到过一两次频率极低就先不管了。如果对这类短信有硬需求可以再研究ATCNMI第三个参数配合主动上报内容的方案后续有机会单独写一篇。5.4 长期运行的维护经验短信转发服务跑起来之后日常维护其实很少。但有三件事值得养成习惯。第一定期看一眼日志确认没有大量推送失败的报错。第二如果发现某张卡的短信特别多比如被订阅了垃圾短信注意modem存储占用。脚本虽然推送后删除但如果推送连续失败短信会一直堆积这时候要及时检查webhook是否还有效。第三webhook地址里包含密钥注意别把服务文件或日志传到公开平台也不要在贴代码时把自己的key暴露出去。6. 还能怎么玩从短信转发到自动化中枢最后再聊聊这个功能还能往哪个方向扩展。短信转发只是把短信从modem里“接出来”的第一步接出来之后可以把它接进任何自动化系统。比如给OpenStick上的Home Assistant发一条MQTT消息让智能家居根据短信内容执行动作或者解析验证码短信自动填入某个表单又或者把短信作为备用认证通道做成双因素认证的接收端。思路打开之后这块几十块钱的板子就变成一个很灵活的通信中心了。我在实际使用中没有给脚本加太多黑魔法因为这种常驻服务稳定比功能炫酷更重要。踩过几次坑之后我最大的体会是先用手动AT指令把整条链路摸清楚再交给脚本去自动化比直接抄一段代码跑要可靠得多。只要modem、串口、AT指令这三个环节没问题Python只是很薄的一层胶水翻车概率非常低。如果你手头正好有一台刷了Debian的高通410随身WiFi建议按这个流程试一下。装好环境、写个脚本、挂上服务半小时就能把这台小机器的短信接进手机以后收验证码再也不用蹲在弱电箱旁边拆板子了。