Compare commits
3
Commits
e87e5fc9de
...
21a8b97bb2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21a8b97bb2 | ||
|
|
cd3e50d413 | ||
|
|
b80636ac32 |
@@ -0,0 +1,353 @@
|
|||||||
|
# SMS Forwarding
|
||||||
|
|
||||||
|
一个运行在 Node.js 上的 4G 短信网关。程序通过串口控制 4G 模组,接收 PDU 短信,合并长短信,并把短信转发到邮件、钉钉、飞书、Telegram、PushPlus、Server 酱、Bark 或自定义 Webhook。
|
||||||
|
|
||||||
|
项目同时提供 Web 管理台和 HTTP API,可用于查看模组状态、收件箱、日志、推送通道,发送短信,切换发送短信使用的 SIM,以及执行 AT 命令。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- 通过 AT 指令和 4G 模组通信
|
||||||
|
- 自动探测可用 AT 串口
|
||||||
|
- PDU 模式接收短信
|
||||||
|
- 长短信自动合并
|
||||||
|
- 收件箱持久化到本地文件
|
||||||
|
- 多推送通道转发短信
|
||||||
|
- Web 管理台
|
||||||
|
- Token 鉴权的 HTTP API
|
||||||
|
- Web/API 发送短信
|
||||||
|
- 双 SIM 状态识别和发送前切卡
|
||||||
|
- 移动数据开关和启动断网保护
|
||||||
|
- AT 命令调试入口
|
||||||
|
|
||||||
|
## 环境要求
|
||||||
|
|
||||||
|
- Node.js 18 或更高版本
|
||||||
|
- 可通过串口访问的 4G 模组
|
||||||
|
- 已插入可收发短信的 SIM 卡
|
||||||
|
- Linux 下通常需要访问 `/dev/ttyUSB*` 的权限
|
||||||
|
|
||||||
|
已主要围绕 ML307A 调试。其他 AT 指令兼容模组可以运行,但双卡、移动数据、URC 卡槽识别等能力取决于模组返回的指令结果。
|
||||||
|
|
||||||
|
## 快速启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
cp config.example.json config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
编辑 `config.json`,至少确认:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"serial": {
|
||||||
|
"path": "/dev/ttyUSB0",
|
||||||
|
"baudRate": 115200,
|
||||||
|
"autoDetect": true
|
||||||
|
},
|
||||||
|
"api": {
|
||||||
|
"port": 3000,
|
||||||
|
"webToken": "change-this-token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
启动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
访问管理台:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:3000/admin
|
||||||
|
```
|
||||||
|
|
||||||
|
第一次访问会要求输入 `api.webToken`。也可以临时通过 URL 传入:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:3000/admin?token=change-this-token
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker 运行
|
||||||
|
|
||||||
|
先准备配置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp config.example.json config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
然后启动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
查看日志:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f sms-gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
`docker-compose.yml` 会挂载:
|
||||||
|
|
||||||
|
- `./config.json:/app/config.json`
|
||||||
|
- `./logs:/app/logs`
|
||||||
|
- `./data:/app/data`
|
||||||
|
- `/dev:/dev`
|
||||||
|
- `/sys:/sys:ro`
|
||||||
|
|
||||||
|
默认端口是 `3000`。如需修改宿主机端口:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SMS_GATEWAY_PORT=8080 docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## PM2 运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
cp config.example.json config.json
|
||||||
|
pm2 start ecosystem.config.js
|
||||||
|
pm2 save
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
`config.json` 必须存在,否则程序会退出。完整模板见 `config.example.json`。
|
||||||
|
|
||||||
|
### 必填项
|
||||||
|
|
||||||
|
| 配置项 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `serial.path` | 串口路径。Linux 常见为 `/dev/ttyUSB0`,Windows 可写 `COM3`。`serial.autoDetect=true` 时它是优先探测项;`false` 时它就是实际打开的串口。 |
|
||||||
|
| `serial.baudRate` | 串口波特率,通常是 `115200`。 |
|
||||||
|
| `api.port` | Web 管理台和 HTTP API 监听端口。 |
|
||||||
|
| `api.webToken` | 唯一访问凭据。项目没有默认账号密码,也没有 `admin/admin123` Basic Auth。 |
|
||||||
|
|
||||||
|
### 可选项
|
||||||
|
|
||||||
|
| 配置项 | 默认值 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `serial.autoDetect` | `true` | 是否自动探测可响应 `AT` 的串口。 |
|
||||||
|
| `serial.probeTimeout` | `1200` | 单个串口探测超时,单位毫秒。 |
|
||||||
|
| `mobileData.cid` | `1` | 移动数据 PDP CID。有效范围 `1-15`,不要使用 `8`。 |
|
||||||
|
| `simCards` | `[]` | 只影响 Web 显示的 SIM 手机号或标签,不影响实际收发。`slot=0` 是 SIM1,`slot=1` 是 SIM2。 |
|
||||||
|
| `smtp` | 未启用 | 配置后会额外发送邮件通知。填写 `smtp.server` 后需要同时正确填写 `port`、`user`、`pass`、`sendTo`。 |
|
||||||
|
| `inbox.maxReceivedMessages` | `200` | 收件箱最多保留条数,代码上限是 `5000`。 |
|
||||||
|
| `inbox.receivedMessagesFile` | `data/received-messages.json` | 收件箱持久化文件。相对路径按项目根目录解析。 |
|
||||||
|
| `inbox.partitionBySim` | `true` | 查询收件箱时是否按 SIM 信息隔离。 |
|
||||||
|
| `inbox.allowAllSimMessages` | `false` | 是否允许显式查询全部 SIM 历史。 |
|
||||||
|
| `inbox.dualSimDefaultScope` | `all` | 双卡场景下默认收件箱范围,可选 `all` 或 `current`。 |
|
||||||
|
| `pushChannels` | `[]` | 推送通道列表。通道 `enabled=true` 时必须填写该类型要求的目标字段。 |
|
||||||
|
|
||||||
|
`api.webToken` 支持以下传入方式:
|
||||||
|
|
||||||
|
- `Authorization: Bearer <token>`
|
||||||
|
- `X-Web-Token: <token>`
|
||||||
|
- `?token=<token>`
|
||||||
|
- Web 管理台登录页
|
||||||
|
|
||||||
|
## 推送通道
|
||||||
|
|
||||||
|
支持的 `pushChannels[].type`:
|
||||||
|
|
||||||
|
| 类型 | 必填字段 |
|
||||||
|
| --- | --- |
|
||||||
|
| `dingtalk` | `url`,`secret` 可选 |
|
||||||
|
| `feishu` | `url`,`secret` 可选 |
|
||||||
|
| `telegram` | `url` 填 Bot Token,`key1` 填 Chat ID |
|
||||||
|
| `pushplus` | `key1` 填 PushPlus Token |
|
||||||
|
| `serverchan` | `key1` 填 Server 酱 SendKey |
|
||||||
|
| `bark` | `url` |
|
||||||
|
| `post_json` | `url` |
|
||||||
|
| `get` | `url` |
|
||||||
|
| `custom` | `url`,`customBody` 可选 |
|
||||||
|
|
||||||
|
`customBody` 必须是合法 JSON 字符串,支持以下占位符:
|
||||||
|
|
||||||
|
- `{sender}`
|
||||||
|
- `{message}`
|
||||||
|
- `{timestamp}`
|
||||||
|
|
||||||
|
`enabled=false` 的通道可以保留空字段;`enabled=true` 时会校验目标字段。
|
||||||
|
|
||||||
|
## SIM 行为
|
||||||
|
|
||||||
|
启动初始化时程序会探测 SIM 状态。运行期间的普通状态刷新不会为了探测卡槽而持续切换 SIM。
|
||||||
|
|
||||||
|
发送短信时使用当前绑定的发送 SIM。Web 管理台的切卡入口放在“发送短信”区域,切换会同时设置模组的 `SWITCHSIM` 和 `BINDSIM`,然后恢复短信直出配置。
|
||||||
|
|
||||||
|
短信接收使用 `CNMI` 直出模式。收到短信时,如果模组 URC 携带卡槽信息,会记录为 SIM1 或 SIM2;如果 URC 没有卡槽信息,收件箱会显示为未知 SIM。
|
||||||
|
|
||||||
|
如果只想给管理台显示手机号,可配置:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"simCards": [
|
||||||
|
{ "slot": 0, "phoneNumber": "+8613800138000" },
|
||||||
|
{ "slot": 1, "phoneNumber": "+447700900000" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## HTTP API
|
||||||
|
|
||||||
|
所有 `/api/*` 接口都需要 token。下面示例使用 `X-Web-Token`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TOKEN=change-this-token
|
||||||
|
BASE=http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 状态和日志
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "X-Web-Token: $TOKEN" "$BASE/api/status"
|
||||||
|
curl -H "X-Web-Token: $TOKEN" "$BASE/api/modem/info"
|
||||||
|
curl -H "X-Web-Token: $TOKEN" "$BASE/api/modem/signal"
|
||||||
|
curl -H "X-Web-Token: $TOKEN" "$BASE/api/logs"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 收件箱
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "X-Web-Token: $TOKEN" "$BASE/api/sms/received?limit=50"
|
||||||
|
curl -H "X-Web-Token: $TOKEN" "$BASE/api/sms/received?limit=50&scope=current"
|
||||||
|
curl -H "X-Web-Token: $TOKEN" "$BASE/api/sms/received?limit=50&simSlot=0"
|
||||||
|
curl -H "X-Web-Token: $TOKEN" "$BASE/api/sms/received?limit=50&simSlot=1"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 发送短信
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "$BASE/api/sms/send" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Web-Token: $TOKEN" \
|
||||||
|
-d '{"phone":"13800138000","message":"测试短信"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### SIM 状态和切换
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "X-Web-Token: $TOKEN" "$BASE/api/modem/sim"
|
||||||
|
|
||||||
|
curl -X POST "$BASE/api/modem/sim/switch" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Web-Token: $TOKEN" \
|
||||||
|
-d '{"slot":1}'
|
||||||
|
```
|
||||||
|
|
||||||
|
`slot=0` 表示 SIM1,`slot=1` 表示 SIM2。
|
||||||
|
|
||||||
|
### 移动数据
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "X-Web-Token: $TOKEN" "$BASE/api/modem/mobile-data"
|
||||||
|
|
||||||
|
curl -X POST "$BASE/api/modem/mobile-data" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Web-Token: $TOKEN" \
|
||||||
|
-d '{"enabled":false}'
|
||||||
|
|
||||||
|
curl -X POST "$BASE/api/modem/mobile-data/consume" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Web-Token: $TOKEN" \
|
||||||
|
-d '{"target":"8.8.8.8"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### AT 命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "$BASE/api/modem/at" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Web-Token: $TOKEN" \
|
||||||
|
-d '{"command":"AT+CSQ"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 推送通道管理
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "X-Web-Token: $TOKEN" "$BASE/api/push/channels"
|
||||||
|
|
||||||
|
curl -X PUT "$BASE/api/push/channels" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Web-Token: $TOKEN" \
|
||||||
|
-d '{"channels":[]}'
|
||||||
|
|
||||||
|
curl -X POST "$BASE/api/push/test" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Web-Token: $TOKEN" \
|
||||||
|
-d '{"enabled":true,"type":"post_json","name":"测试","url":"https://example.com/webhook"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
.
|
||||||
|
├── src/
|
||||||
|
│ ├── index.js # 程序入口
|
||||||
|
│ ├── modem.js # 串口、AT 指令、SIM 和移动数据控制
|
||||||
|
│ ├── sms.js # PDU 解析、长短信处理后的转发、收件箱记录
|
||||||
|
│ ├── concat.js # 长短信分段合并
|
||||||
|
│ ├── push.js # 推送通道
|
||||||
|
│ ├── api.js # Web 管理台和 HTTP API
|
||||||
|
│ └── logger.js # 日志
|
||||||
|
├── public/ # Web 管理台静态文件
|
||||||
|
├── data/ # 收件箱持久化数据
|
||||||
|
├── logs/ # 运行日志
|
||||||
|
├── config.example.json
|
||||||
|
├── config.json # 本地配置,不应提交
|
||||||
|
├── docker-compose.yml
|
||||||
|
├── Dockerfile
|
||||||
|
└── ecosystem.config.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### 串口打不开
|
||||||
|
|
||||||
|
Linux 下先确认设备存在:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -l /dev/ttyUSB*
|
||||||
|
```
|
||||||
|
|
||||||
|
如果是权限问题,把运行用户加入串口权限组,重新登录后生效:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo usermod -a -G dialout "$USER"
|
||||||
|
```
|
||||||
|
|
||||||
|
Docker 场景下确认 `docker-compose.yml` 已挂载 `/dev`,并保留了 `device_cgroup_rules`。
|
||||||
|
|
||||||
|
### 自动探测不到模组
|
||||||
|
|
||||||
|
- 确认 USB 连接和供电。
|
||||||
|
- 确认 `serial.baudRate` 正确。
|
||||||
|
- 临时关闭自动探测,把 `serial.autoDetect` 设为 `false`,并把 `serial.path` 写成真实 AT 串口。
|
||||||
|
- 查看日志中每个候选串口的探测结果。
|
||||||
|
|
||||||
|
### 收不到短信
|
||||||
|
|
||||||
|
- 确认 SIM 卡已插好且可以注册网络。
|
||||||
|
- 查看 `/api/status` 里的 `ready`、`operator`、`signal`。
|
||||||
|
- 确认日志里有 `PDU模式设置完成` 和 `CNMI参数设置完成`。
|
||||||
|
- 部分模组不会在 URC 里带卡槽信息,这只影响收件箱显示的 SIM 归属,不代表短信没有收到。
|
||||||
|
|
||||||
|
### 发送短信失败
|
||||||
|
|
||||||
|
- 先在管理台确认当前发送 SIM。
|
||||||
|
- 确认目标号码格式正确。
|
||||||
|
- 查看日志里的 AT 响应。
|
||||||
|
- 如果刚切换 SIM,等待模组重新注册网络后再发。
|
||||||
|
|
||||||
|
## 安全说明
|
||||||
|
|
||||||
|
- 不要提交 `config.json`、token、手机号、Webhook URL、SMTP 密码和日志。
|
||||||
|
- 管理台和 API 只使用 `api.webToken` 鉴权。
|
||||||
|
- `POST /api/modem/at` 可以执行任意 AT 命令,只应暴露在可信网络内。
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
+24
-6
@@ -4,19 +4,40 @@
|
|||||||
"baudRate": 115200,
|
"baudRate": 115200,
|
||||||
"autoDetect": true,
|
"autoDetect": true,
|
||||||
"probeTimeout": 1200,
|
"probeTimeout": 1200,
|
||||||
"_comment": "Windows使用COM1/COM2/COM3等,也可以直接写数字如3; Linux使用/dev/ttyUSB0"
|
"_comment": "必填。path是手动串口或自动探测优先项;autoDetect=false时必须写真实串口。Windows使用COM1/COM2/COM3等,也可以直接写数字如3;Linux使用/dev/ttyUSB0。baudRate通常为115200,probeTimeout为AT探测超时毫秒。"
|
||||||
},
|
},
|
||||||
"mobileData": {
|
"mobileData": {
|
||||||
"cid": 1,
|
"cid": 1,
|
||||||
"_comment": "每次启动都会强制关闭移动数据,管理台开关只影响当前运行期; ML307 cid8为IMS专用,请勿使用"
|
"_comment": "可选,默认cid=1。有效范围1-15,cid8为IMS专用,请勿使用。ML307每次启动都会强制断开MIPCALL应用层拨号,并保护性尝试CGACT;管理台开关只影响当前运行期。"
|
||||||
},
|
},
|
||||||
|
"_simCards_comment": "可选。用于给SIM1/SIM2显示手机号;slot=0是SIM1,slot=1是SIM2。也兼容旧字段名simSlots、sims、sim。",
|
||||||
|
"simCards": [
|
||||||
|
{
|
||||||
|
"slot": 0,
|
||||||
|
"phoneNumber": "+8613800138000"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slot": 1,
|
||||||
|
"phoneNumber": "+447700900000"
|
||||||
|
}
|
||||||
|
],
|
||||||
"smtp": {
|
"smtp": {
|
||||||
|
"_comment": "可选。填写server后会启用邮件通知;启用时server、port、user、pass、sendTo都需要正确填写。不需要邮件可删除smtp或清空server。",
|
||||||
"server": "smtp.qq.com",
|
"server": "smtp.qq.com",
|
||||||
"port": 465,
|
"port": 465,
|
||||||
"user": "your@qq.com",
|
"user": "your@qq.com",
|
||||||
"pass": "your_auth_code",
|
"pass": "your_auth_code",
|
||||||
"sendTo": "recipient@example.com"
|
"sendTo": "recipient@example.com"
|
||||||
},
|
},
|
||||||
|
"inbox": {
|
||||||
|
"maxReceivedMessages": 200,
|
||||||
|
"receivedMessagesFile": "data/received-messages.json",
|
||||||
|
"partitionBySim": true,
|
||||||
|
"allowAllSimMessages": false,
|
||||||
|
"dualSimDefaultScope": "all",
|
||||||
|
"_comment": "全部可选。maxReceivedMessages默认200且最大5000;receivedMessagesFile默认data/received-messages.json;partitionBySim默认true;allowAllSimMessages默认false;dualSimDefaultScope可为all或current。默认显示全部短信;直出URC未携带卡槽时,新短信不区分SIM1/SIM2收件归属。"
|
||||||
|
},
|
||||||
|
"_pushChannels_comment": "可选。enabled=false时目标字段可以为空;enabled=true时按type要求填写目标:post_json/bark/get/dingtalk/custom/feishu需要url,telegram需要url(bot token)+key1(chat id),pushplus需要key1(token),serverchan需要key1(sendkey)。customBody如果填写必须是合法JSON字符串。",
|
||||||
"pushChannels": [
|
"pushChannels": [
|
||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -72,9 +93,6 @@
|
|||||||
"api": {
|
"api": {
|
||||||
"port": 3000,
|
"port": 3000,
|
||||||
"webToken": "change-this-token",
|
"webToken": "change-this-token",
|
||||||
"auth": {
|
"_comment": "必填。port是API和管理台监听端口;webToken是唯一鉴权凭据,可通过Authorization: Bearer、X-Web-Token、token查询参数或管理台登录页提交。"
|
||||||
"username": "admin",
|
|
||||||
"password": "admin123"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-3
@@ -144,7 +144,6 @@
|
|||||||
<span class="sim-slot__state" id="simSlot0State">--</span>
|
<span class="sim-slot__state" id="simSlot0State">--</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="sim-slot__phone" id="simSlot0Phone">--</span>
|
<span class="sim-slot__phone" id="simSlot0Phone">--</span>
|
||||||
<button class="button button--secondary button--compact" type="button" data-sim-switch="0">切到SIM1</button>
|
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="sim-slot" id="simSlot1">
|
<article class="sim-slot" id="simSlot1">
|
||||||
@@ -153,7 +152,6 @@
|
|||||||
<span class="sim-slot__state" id="simSlot1State">--</span>
|
<span class="sim-slot__state" id="simSlot1State">--</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="sim-slot__phone" id="simSlot1Phone">--</span>
|
<span class="sim-slot__phone" id="simSlot1Phone">--</span>
|
||||||
<button class="button button--secondary button--compact" type="button" data-sim-switch="1">切到SIM2</button>
|
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -227,6 +225,10 @@
|
|||||||
<span>当前发送SIM</span>
|
<span>当前发送SIM</span>
|
||||||
<strong id="sendSimLabel">--</strong>
|
<strong id="sendSimLabel">--</strong>
|
||||||
</p>
|
</p>
|
||||||
|
<div class="send-sim-picker" aria-label="选择发送SIM">
|
||||||
|
<button class="button button--secondary button--compact" type="button" data-sim-switch="0">SIM1</button>
|
||||||
|
<button class="button button--secondary button--compact" type="button" data-sim-switch="1">SIM2</button>
|
||||||
|
</div>
|
||||||
<form id="smsForm" class="stack-form">
|
<form id="smsForm" class="stack-form">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="field__label">目标号码</span>
|
<span class="field__label">目标号码</span>
|
||||||
@@ -419,4 +421,4 @@
|
|||||||
<script src="/assets/admin.js" type="module"></script>
|
<script src="/assets/admin.js" type="module"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+31
-7
@@ -70,6 +70,10 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
scroll-padding-top: 80px;
|
scroll-padding-top: 80px;
|
||||||
@@ -726,9 +730,13 @@ h3 {
|
|||||||
gap: var(--space-md);
|
gap: var(--space-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sim-slot-grid.is-single {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
.sim-slot {
|
.sim-slot {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px 12px;
|
gap: 8px 12px;
|
||||||
border: 1px solid var(--hairline);
|
border: 1px solid var(--hairline);
|
||||||
@@ -737,9 +745,14 @@ h3 {
|
|||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sim-slot.is-single {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
min-height: 76px;
|
||||||
|
}
|
||||||
|
|
||||||
.sim-slot.is-active {
|
.sim-slot.is-active {
|
||||||
border-color: rgba(93, 184, 114, 0.55);
|
border-color: var(--surface-cream-strong);
|
||||||
box-shadow: inset 3px 0 0 var(--success);
|
background: var(--surface-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sim-slot__topline {
|
.sim-slot__topline {
|
||||||
@@ -769,8 +782,8 @@ h3 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sim-slot.is-active .sim-slot__state {
|
.sim-slot.is-active .sim-slot__state {
|
||||||
background: rgba(93, 184, 114, 0.16);
|
background: var(--surface-cream-strong);
|
||||||
color: #2f7c44;
|
color: var(--body-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sim-slot__phone {
|
.sim-slot__phone {
|
||||||
@@ -966,7 +979,7 @@ h3 {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin: -4px 0 var(--space-md);
|
margin: -4px 0 8px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -984,6 +997,17 @@ h3 {
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.send-sim-picker {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0 0 var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-sim-picker .button {
|
||||||
|
min-width: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
.field {
|
.field {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-xs);
|
gap: var(--space-xs);
|
||||||
@@ -1592,4 +1616,4 @@ body.modal-open {
|
|||||||
.field-row--wide {
|
.field-row--wide {
|
||||||
grid-column: auto;
|
grid-column: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+80
-11
@@ -50,6 +50,7 @@ const elements = {
|
|||||||
simStatus: document.querySelector('#simStatus'),
|
simStatus: document.querySelector('#simStatus'),
|
||||||
simSlot0: document.querySelector('#simSlot0'),
|
simSlot0: document.querySelector('#simSlot0'),
|
||||||
simSlot1: document.querySelector('#simSlot1'),
|
simSlot1: document.querySelector('#simSlot1'),
|
||||||
|
simSlotGrid: document.querySelector('.sim-slot-grid'),
|
||||||
simSlot0State: document.querySelector('#simSlot0State'),
|
simSlot0State: document.querySelector('#simSlot0State'),
|
||||||
simSlot1State: document.querySelector('#simSlot1State'),
|
simSlot1State: document.querySelector('#simSlot1State'),
|
||||||
simSlot0Phone: document.querySelector('#simSlot0Phone'),
|
simSlot0Phone: document.querySelector('#simSlot0Phone'),
|
||||||
@@ -253,6 +254,11 @@ function renderSendSimLabel() {
|
|||||||
setText(elements.sendSimLabel, getActiveSimLabel());
|
setText(elements.sendSimLabel, getActiveSimLabel());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setSimSwitchStatus(message, type = '') {
|
||||||
|
setStatusMessage(elements.simStatus, message);
|
||||||
|
setStatusMessage(elements.smsStatus, message, type);
|
||||||
|
}
|
||||||
|
|
||||||
function getSimSlot(sim = {}, slotNumber) {
|
function getSimSlot(sim = {}, slotNumber) {
|
||||||
return (sim.slots || []).find(slot => slot.slot === slotNumber) || {
|
return (sim.slots || []).find(slot => slot.slot === slotNumber) || {
|
||||||
slot: slotNumber,
|
slot: slotNumber,
|
||||||
@@ -281,7 +287,18 @@ function renderSimSlot(slot, card, stateNode, phoneNode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
card.classList.toggle('is-active', Boolean(slot.active));
|
card.classList.toggle('is-active', Boolean(slot.active));
|
||||||
setText(stateNode, slot.active ? '当前使用' : '未选中');
|
if (slot.active && slot.present === false) {
|
||||||
|
setText(stateNode, '当前未就绪');
|
||||||
|
} else if (slot.active) {
|
||||||
|
setText(stateNode, '当前使用');
|
||||||
|
} else if (slot.present === true) {
|
||||||
|
setText(stateNode, '可用');
|
||||||
|
} else if (slot.present === false) {
|
||||||
|
setText(stateNode, '未检测到SIM');
|
||||||
|
} else {
|
||||||
|
setText(stateNode, '未确认');
|
||||||
|
}
|
||||||
|
|
||||||
const phoneText = formatSimSlotPhone(slot);
|
const phoneText = formatSimSlotPhone(slot);
|
||||||
if (phoneNode) {
|
if (phoneNode) {
|
||||||
phoneNode.textContent = phoneText;
|
phoneNode.textContent = phoneText;
|
||||||
@@ -289,29 +306,81 @@ function renderSimSlot(slot, card, stateNode, phoneNode) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getVisibleSimSlots(sim = {}, activeSlot = null) {
|
||||||
|
const slots = sim.slots || [];
|
||||||
|
const presentSlots = slots.filter(slot => slot.present === true);
|
||||||
|
|
||||||
|
if (presentSlots.length >= 2) {
|
||||||
|
return presentSlots;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeSlot?.present === true) {
|
||||||
|
return [activeSlot];
|
||||||
|
}
|
||||||
|
|
||||||
|
return slots.filter(slot => slot.active || slot.present !== false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSimModeLabel(sim = {}, activeSlot = null) {
|
||||||
|
const hardwareSwitchable = Boolean(sim.supported && sim.canSwitch);
|
||||||
|
if (!hardwareSwitchable) {
|
||||||
|
return '单卡/未检测到双卡';
|
||||||
|
}
|
||||||
|
|
||||||
|
const presentSlots = (sim.slots || []).filter(slot => slot.present === true);
|
||||||
|
if (presentSlots.length >= 2) {
|
||||||
|
return sim.modeLabel || '双卡';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeSlot?.present === true) {
|
||||||
|
return `仅检测到 ${getSimDisplayLabel(activeSlot)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '未检测到可用SIM';
|
||||||
|
}
|
||||||
|
|
||||||
function renderSimStatus(sim = {}) {
|
function renderSimStatus(sim = {}) {
|
||||||
state.currentSim = sim;
|
state.currentSim = sim;
|
||||||
|
|
||||||
const supported = Boolean(sim.supported && sim.canSwitch);
|
const supported = Boolean(sim.supported && sim.canSwitch);
|
||||||
const activeSlot = getActiveSimSlot(sim);
|
const activeSlot = getActiveSimSlot(sim);
|
||||||
const modeLabel = supported ? (sim.modeLabel || '双卡') : '单卡/未检测到双卡';
|
const modeLabel = getSimModeLabel(sim, activeSlot);
|
||||||
|
|
||||||
setText(elements.simMode, modeLabel);
|
setText(elements.simMode, modeLabel);
|
||||||
|
|
||||||
renderSimSlot(getSimSlot(sim, 0), elements.simSlot0, elements.simSlot0State, elements.simSlot0Phone);
|
const visibleSlots = getVisibleSimSlots(sim, activeSlot);
|
||||||
renderSimSlot(getSimSlot(sim, 1), elements.simSlot1, elements.simSlot1State, elements.simSlot1Phone);
|
const singleSlot = visibleSlots.length === 1;
|
||||||
|
const visibleSlotNumbers = new Set(visibleSlots.map(slot => slot.slot));
|
||||||
|
|
||||||
|
elements.simSlotGrid?.classList.toggle('is-single', singleSlot);
|
||||||
|
|
||||||
|
[
|
||||||
|
[getSimSlot(sim, 0), elements.simSlot0, elements.simSlot0State, elements.simSlot0Phone],
|
||||||
|
[getSimSlot(sim, 1), elements.simSlot1, elements.simSlot1State, elements.simSlot1Phone]
|
||||||
|
].forEach(([slot, card, stateNode, phoneNode]) => {
|
||||||
|
if (card) {
|
||||||
|
card.hidden = !visibleSlotNumbers.has(slot.slot);
|
||||||
|
card.classList.toggle('is-single', singleSlot && visibleSlotNumbers.has(slot.slot));
|
||||||
|
}
|
||||||
|
renderSimSlot(slot, card, stateNode, phoneNode);
|
||||||
|
});
|
||||||
|
|
||||||
elements.simSwitchButtons.forEach((button) => {
|
elements.simSwitchButtons.forEach((button) => {
|
||||||
const slot = Number.parseInt(button.dataset.simSwitch, 10);
|
const slot = Number.parseInt(button.dataset.simSwitch, 10);
|
||||||
|
const slotInfo = getSimSlot(sim, slot);
|
||||||
const isActive = activeSlot?.slot === slot;
|
const isActive = activeSlot?.slot === slot;
|
||||||
button.disabled = state.simBusy || Boolean(sim.switching) || !supported || isActive;
|
const unavailable = slotInfo.present === false;
|
||||||
button.textContent = isActive ? '正在使用' : `切到SIM${slot + 1}`;
|
button.hidden = singleSlot;
|
||||||
|
button.disabled = state.simBusy || Boolean(sim.switching) || !supported || isActive || unavailable;
|
||||||
|
button.textContent = isActive ? `SIM${slot + 1} 当前` : `切到SIM${slot + 1}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (state.simBusy || sim.switching) {
|
if (state.simBusy || sim.switching) {
|
||||||
setStatusMessage(elements.simStatus, '正在切换SIM...');
|
setSimSwitchStatus('正在切换SIM...');
|
||||||
} else if (supported && activeSlot) {
|
} else if (supported && activeSlot?.present === true) {
|
||||||
setStatusMessage(elements.simStatus, `当前 ${getSimDisplayLabel(activeSlot)}`, 'success');
|
setStatusMessage(elements.simStatus, `当前 ${getSimDisplayLabel(activeSlot)}`);
|
||||||
|
} else if (supported && activeSlot?.present === false) {
|
||||||
|
setStatusMessage(elements.simStatus, `${getSimDisplayLabel(activeSlot)} 未就绪,请检查是否插卡`);
|
||||||
} else if (!supported) {
|
} else if (!supported) {
|
||||||
setStatusMessage(elements.simStatus, '双卡不可用');
|
setStatusMessage(elements.simStatus, '双卡不可用');
|
||||||
} else {
|
} else {
|
||||||
@@ -803,10 +872,10 @@ async function switchSim(slot) {
|
|||||||
body: JSON.stringify({ slot: slotNumber })
|
body: JSON.stringify({ slot: slotNumber })
|
||||||
});
|
});
|
||||||
renderSimStatus(result.data || {});
|
renderSimStatus(result.data || {});
|
||||||
setStatusMessage(elements.simStatus, result.message || `已切换到SIM${slotNumber + 1}`, 'success');
|
setSimSwitchStatus(result.message || `已切换到SIM${slotNumber + 1}`, 'success');
|
||||||
await refreshAll();
|
await refreshAll();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatusMessage(elements.simStatus, err.message, 'error');
|
setSimSwitchStatus(err.message, 'error');
|
||||||
await refreshSim().catch(() => {});
|
await refreshSim().catch(() => {});
|
||||||
} finally {
|
} finally {
|
||||||
state.simBusy = false;
|
state.simBusy = false;
|
||||||
|
|||||||
+229
-17
@@ -33,6 +33,7 @@ class ModemManager extends EventEmitter {
|
|||||||
switchSlot: null,
|
switchSlot: null,
|
||||||
bindSlot: null,
|
bindSlot: null,
|
||||||
switching: false,
|
switching: false,
|
||||||
|
probing: false,
|
||||||
slots: this.createSimSlots(),
|
slots: this.createSimSlots(),
|
||||||
lastCheckedAt: null,
|
lastCheckedAt: null,
|
||||||
error: '',
|
error: '',
|
||||||
@@ -63,6 +64,7 @@ class ModemManager extends EventEmitter {
|
|||||||
slot,
|
slot,
|
||||||
name: `SIM${slot + 1}`,
|
name: `SIM${slot + 1}`,
|
||||||
active: false,
|
active: false,
|
||||||
|
present: null,
|
||||||
phoneNumber,
|
phoneNumber,
|
||||||
phoneLabel: phoneNumber || `SIM${slot + 1}`,
|
phoneLabel: phoneNumber || `SIM${slot + 1}`,
|
||||||
lastCheckedAt: null
|
lastCheckedAt: null
|
||||||
@@ -538,6 +540,25 @@ class ModemManager extends EventEmitter {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateSimSlotPresence(status, slotNumber, present) {
|
||||||
|
const slot = this.normalizeSimSlot(slotNumber);
|
||||||
|
if (slot === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
status.slots = status.slots.map(item => {
|
||||||
|
if (item.slot !== slot) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
present: Boolean(present),
|
||||||
|
lastCheckedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
getBusinessSimSlot(status = this.sim) {
|
getBusinessSimSlot(status = this.sim) {
|
||||||
return this.normalizeSimSlot(status.bindSlot) ?? this.normalizeSimSlot(status.switchSlot);
|
return this.normalizeSimSlot(status.bindSlot) ?? this.normalizeSimSlot(status.switchSlot);
|
||||||
}
|
}
|
||||||
@@ -637,14 +658,27 @@ class ModemManager extends EventEmitter {
|
|||||||
retryDelay = 2000,
|
retryDelay = 2000,
|
||||||
settleDelay = 3000,
|
settleDelay = 3000,
|
||||||
confirmTimeout = 15000,
|
confirmTimeout = 15000,
|
||||||
confirmInterval = 1000
|
confirmInterval = 1000,
|
||||||
|
simReadyAttempts = 10,
|
||||||
|
simReadyDelay = 1000
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const currentSlot = await this.querySwitchSimSlot().catch(() => this.sim.switchSlot);
|
const currentSlot = await this.querySwitchSimSlot().catch(() => this.sim.switchSlot);
|
||||||
this.sim.switchSlot = currentSlot;
|
this.sim.switchSlot = currentSlot;
|
||||||
|
|
||||||
if (currentSlot === targetSlot) {
|
if (currentSlot === targetSlot) {
|
||||||
await this.waitSIMReady();
|
try {
|
||||||
|
await this.waitSIMReady({
|
||||||
|
attempts: simReadyAttempts,
|
||||||
|
delay: simReadyDelay,
|
||||||
|
label: `SIM${targetSlot + 1}`
|
||||||
|
});
|
||||||
|
this.updateSimSlotPresence(this.sim, targetSlot, true);
|
||||||
|
} catch (err) {
|
||||||
|
this.updateSimSlotPresence(this.sim, targetSlot, false);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
return targetSlot;
|
return targetSlot;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -674,11 +708,155 @@ class ModemManager extends EventEmitter {
|
|||||||
this.sim.switchSlot = targetSlot;
|
this.sim.switchSlot = targetSlot;
|
||||||
this.markActiveSimSlot(this.sim, this.getBusinessSimSlot(this.sim) ?? targetSlot);
|
this.markActiveSimSlot(this.sim, this.getBusinessSimSlot(this.sim) ?? targetSlot);
|
||||||
await this.sleep(settleDelay);
|
await this.sleep(settleDelay);
|
||||||
await this.waitSIMReady();
|
|
||||||
|
try {
|
||||||
|
await this.waitSIMReady({
|
||||||
|
attempts: simReadyAttempts,
|
||||||
|
delay: simReadyDelay,
|
||||||
|
label: `SIM${targetSlot + 1}`
|
||||||
|
});
|
||||||
|
this.updateSimSlotPresence(this.sim, targetSlot, true);
|
||||||
|
} catch (err) {
|
||||||
|
this.updateSimSlotPresence(this.sim, targetSlot, false);
|
||||||
|
await this.restoreSwitchSimSlot(currentSlot, `${context}失败后回退`).catch((restoreErr) => {
|
||||||
|
logger.warn(`${context}失败后回退SIM失败: ${restoreErr.message}`);
|
||||||
|
});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
return targetSlot;
|
return targetSlot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async restoreSwitchSimSlot(slotNumber, context = 'SIM回退', timeout = 20000) {
|
||||||
|
const slot = this.normalizeSimSlot(slotNumber);
|
||||||
|
if (slot === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn(`${context}: 恢复到SIM${slot + 1}`);
|
||||||
|
await this.sendATCommand(`AT+SWITCHSIM=${slot}`, 10000).catch((err) => {
|
||||||
|
logger.warn(`${context}命令失败: ${err.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const restored = await this.waitForSwitchSimSlot(slot, timeout, 1000);
|
||||||
|
if (restored) {
|
||||||
|
this.sim.switchSlot = slot;
|
||||||
|
this.markActiveSimSlot(this.sim, this.getBusinessSimSlot(this.sim) ?? slot);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn(`${context}: 未能确认恢复到SIM${slot + 1}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async restoreBindSimSlot(slotNumber, context = '业务SIM回退') {
|
||||||
|
const slot = this.normalizeSimSlot(slotNumber);
|
||||||
|
if (slot === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.sendATWithRetry(`AT+BINDSIM=${slot}`, {
|
||||||
|
timeout: 5000,
|
||||||
|
retries: 1,
|
||||||
|
retryDelay: 500,
|
||||||
|
label: `${context}到SIM${slot + 1}`
|
||||||
|
}).catch((err) => {
|
||||||
|
logger.warn(`${context}到SIM${slot + 1}失败: ${err.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async restoreSimAfterProbe(switchSlot, bindSlot, context = 'SIM探测后恢复') {
|
||||||
|
const targetSwitchSlot = this.normalizeSimSlot(switchSlot);
|
||||||
|
const targetBindSlot = this.normalizeSimSlot(bindSlot) ?? targetSwitchSlot;
|
||||||
|
|
||||||
|
if (targetSwitchSlot !== null) {
|
||||||
|
await this.restoreSwitchSimSlot(targetSwitchSlot, context).catch((err) => {
|
||||||
|
logger.warn(`${context}切回SIM${targetSwitchSlot + 1}失败: ${err.message}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetBindSlot !== null) {
|
||||||
|
await this.restoreBindSimSlot(targetBindSlot, context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async probeInactiveSimSlot(status, slotNumber, originalSlot, originalBindSlot = originalSlot) {
|
||||||
|
const targetSlot = this.normalizeSimSlot(slotNumber);
|
||||||
|
const restoreSlot = this.normalizeSimSlot(originalSlot);
|
||||||
|
const restoreBindSlot = this.normalizeSimSlot(originalBindSlot) ?? restoreSlot;
|
||||||
|
if (targetSlot === null || restoreSlot === null || targetSlot === restoreSlot) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`探测SIM${targetSlot + 1}是否可用`);
|
||||||
|
let switched = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await this.sendATCommand(`AT+SWITCHSIM=${targetSlot}`, 10000);
|
||||||
|
if (!resp.includes('OK')) {
|
||||||
|
logger.warn(`探测SIM${targetSlot + 1}切换返回非OK: ${this.formatATResponse(resp)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
switched = await this.waitForSwitchSimSlot(targetSlot, 15000, 1000);
|
||||||
|
if (!switched) {
|
||||||
|
this.updateSimSlotPresence(status, targetSlot, false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.sleep(1000);
|
||||||
|
await this.waitSIMReady({
|
||||||
|
attempts: 5,
|
||||||
|
delay: 1000,
|
||||||
|
label: `SIM${targetSlot + 1}`
|
||||||
|
});
|
||||||
|
this.updateSimSlotPresence(status, targetSlot, true);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`探测SIM${targetSlot + 1}失败: ${err.message}`);
|
||||||
|
this.updateSimSlotPresence(status, targetSlot, false);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
if (switched) {
|
||||||
|
await this.restoreSimAfterProbe(restoreSlot, restoreBindSlot, `探测SIM${targetSlot + 1}后恢复`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async probeSimSlotPresence(status, currentSlot) {
|
||||||
|
const originalSlot = this.normalizeSimSlot(currentSlot);
|
||||||
|
if (!status.supported || !status.canSwitch || originalSlot === null) {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.sim.switching || this.sim.probing) {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalBindSlot = this.normalizeSimSlot(status.bindSlot) ?? originalSlot;
|
||||||
|
|
||||||
|
this.sim.probing = true;
|
||||||
|
try {
|
||||||
|
for (const slot of status.slots) {
|
||||||
|
if (slot.slot !== originalSlot) {
|
||||||
|
await this.probeInactiveSimSlot(status, slot.slot, originalSlot, originalBindSlot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.restoreSimAfterProbe(originalSlot, originalBindSlot, 'SIM探测结束恢复');
|
||||||
|
|
||||||
|
const restoredSlot = await this.querySwitchSimSlot().catch(() => originalSlot);
|
||||||
|
status.switchSlot = this.normalizeSimSlot(restoredSlot) ?? originalSlot;
|
||||||
|
status.bindSlot = await this.queryBindSimSlot();
|
||||||
|
this.markActiveSimSlot(status, this.getBusinessSimSlot(status));
|
||||||
|
return status;
|
||||||
|
} finally {
|
||||||
|
this.sim.probing = false;
|
||||||
|
status.probing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async waitForSwitchSimSlot(targetSlot, timeout = 15000, interval = 1000) {
|
async waitForSwitchSimSlot(targetSlot, timeout = 15000, interval = 1000) {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
|
|
||||||
@@ -806,12 +984,27 @@ class ModemManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isSimReadyResponse(resp) {
|
||||||
|
return String(resp || '').includes('+CPIN: READY');
|
||||||
|
}
|
||||||
|
|
||||||
|
async isCurrentSimReady(timeout = 2000) {
|
||||||
|
try {
|
||||||
|
const resp = await this.sendATCommand('AT+CPIN?', timeout);
|
||||||
|
return this.isSimReadyResponse(resp);
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug(`查询当前SIM就绪状态失败: ${err.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getSimStatus(options = {}) {
|
async getSimStatus(options = {}) {
|
||||||
return await this.refreshSimStatus(options);
|
return await this.refreshSimStatus(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshSimStatus(options = {}) {
|
async refreshSimStatus(options = {}) {
|
||||||
const refreshIdentity = options.refreshIdentity === true;
|
const refreshIdentity = options.refreshIdentity === true;
|
||||||
|
const probeSlots = options.probeSlots === true;
|
||||||
const status = {
|
const status = {
|
||||||
...this.sim,
|
...this.sim,
|
||||||
slots: this.sim.slots.map(slot => ({ ...slot })),
|
slots: this.sim.slots.map(slot => ({ ...slot })),
|
||||||
@@ -839,6 +1032,8 @@ class ModemManager extends EventEmitter {
|
|||||||
if (switchSlot !== null) {
|
if (switchSlot !== null) {
|
||||||
status.canSwitch = true;
|
status.canSwitch = true;
|
||||||
status.switchSlot = switchSlot;
|
status.switchSlot = switchSlot;
|
||||||
|
const currentSimReady = await this.isCurrentSimReady();
|
||||||
|
this.updateSimSlotPresence(status, switchSlot, currentSimReady);
|
||||||
} else {
|
} else {
|
||||||
status.switchSlot = null;
|
status.switchSlot = null;
|
||||||
status.error = '未能读取当前AT SIM卡槽';
|
status.error = '未能读取当前AT SIM卡槽';
|
||||||
@@ -852,6 +1047,10 @@ class ModemManager extends EventEmitter {
|
|||||||
this.updateSimSlotPhoneNumber(status, switchSlot, phoneNumber);
|
this.updateSimSlotPhoneNumber(status, switchSlot, phoneNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (probeSlots && status.canSwitch && switchSlot !== null) {
|
||||||
|
await this.probeSimSlotPresence(status, switchSlot);
|
||||||
|
}
|
||||||
|
|
||||||
status.lastCheckedAt = new Date().toISOString();
|
status.lastCheckedAt = new Date().toISOString();
|
||||||
this.sim = status;
|
this.sim = status;
|
||||||
return this.getCachedSimStatus();
|
return this.getCachedSimStatus();
|
||||||
@@ -896,12 +1095,20 @@ class ModemManager extends EventEmitter {
|
|||||||
await this.switchSimForIdentityRead(targetSlot, {
|
await this.switchSimForIdentityRead(targetSlot, {
|
||||||
context: `切换SIM${targetSlot + 1}`,
|
context: `切换SIM${targetSlot + 1}`,
|
||||||
purpose: '用于业务绑定',
|
purpose: '用于业务绑定',
|
||||||
retries: 4,
|
retries: 2,
|
||||||
retryDelay: 3000,
|
retryDelay: 1000,
|
||||||
settleDelay: 3000
|
settleDelay: 1000,
|
||||||
|
confirmTimeout: 5000,
|
||||||
|
confirmInterval: 1000,
|
||||||
|
simReadyAttempts: 3,
|
||||||
|
simReadyDelay: 1000
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await this.waitSIMReady();
|
await this.waitSIMReady({
|
||||||
|
attempts: 3,
|
||||||
|
delay: 1000,
|
||||||
|
label: `SIM${targetSlot + 1}`
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.normalizeSimSlot(before.bindSlot) !== targetSlot) {
|
if (this.normalizeSimSlot(before.bindSlot) !== targetSlot) {
|
||||||
@@ -1753,7 +1960,7 @@ class ModemManager extends EventEmitter {
|
|||||||
|
|
||||||
// 7. 按文档配置短信功能,并启用本项目需要的PDU模式
|
// 7. 按文档配置短信功能,并启用本项目需要的PDU模式
|
||||||
await this.configureSMS();
|
await this.configureSMS();
|
||||||
await this.refreshSimStatus({ refreshIdentity: true });
|
await this.refreshSimStatus({ refreshIdentity: true, probeSlots: true });
|
||||||
|
|
||||||
logger.info('模组初始化完成');
|
logger.info('模组初始化完成');
|
||||||
this.emit('ready');
|
this.emit('ready');
|
||||||
@@ -1762,24 +1969,29 @@ class ModemManager extends EventEmitter {
|
|||||||
/**
|
/**
|
||||||
* 等待SIM卡完成初始化
|
* 等待SIM卡完成初始化
|
||||||
*/
|
*/
|
||||||
async waitSIMReady() {
|
async waitSIMReady(options = {}) {
|
||||||
for (let attempt = 1; attempt <= 10; attempt++) {
|
const attempts = options.attempts ?? 10;
|
||||||
|
const delay = options.delay ?? 1000;
|
||||||
|
const timeout = options.timeout ?? 2000;
|
||||||
|
const label = options.label || 'SIM卡';
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||||||
try {
|
try {
|
||||||
const resp = await this.sendATCommand('AT+CPIN?', 2000);
|
const resp = await this.sendATCommand('AT+CPIN?', timeout);
|
||||||
if (resp.includes('+CPIN: READY')) {
|
if (this.isSimReadyResponse(resp)) {
|
||||||
logger.info('✓ SIM卡已就绪');
|
logger.info(`✓ ${label}已就绪`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.warn(`SIM卡未就绪(${attempt}/10): ${this.formatATResponse(resp)}`);
|
logger.warn(`${label}未就绪(${attempt}/${attempts}): ${this.formatATResponse(resp)}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn(`查询SIM卡状态失败(${attempt}/10): ${err.message}`);
|
logger.warn(`查询${label}状态失败(${attempt}/${attempts}): ${err.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.sleep(1000);
|
await this.sleep(delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error('SIM卡未就绪');
|
throw new Error(`${label}未就绪,可能未插卡或SIM初始化失败`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user