feat: add SIM SMS reader (drivers/sim/sms-reader.js)

Made-with: Cursor
This commit is contained in:
stswangzhiping
2026-04-30 23:18:26 +08:00
parent 2b8ebbc86e
commit 679c1e2051

322
drivers/sim/sms-reader.js Normal file
View File

@@ -0,0 +1,322 @@
'use strict';
/**
* SIM 卡短信读取器
* - 扫描 /dev/serial/by-path 中以 :1.3-port0 结尾的 Quectel AT 串口
* - 每 15 秒读取一次,过滤 5 分钟内的短信
* - 去重后追加写入 ~/sms-messages.txt
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const crypto = require('crypto');
const { execSync } = require('child_process');
const BY_PATH_DIR = '/dev/serial/by-path';
const TARGET_SUFFIX = ':1.3-port0';
const OUT_FILE = path.join(os.homedir(), 'sms-messages.txt');
const STATE_FILE = path.join(os.homedir(), '.sms-seen.json');
const BAUD = 9600;
const POLL_MS = 15_000;
const WINDOW_MINUTES = 5;
const SEEN_LIMIT = 2000;
// ── 工具 ─────────────────────────────────────────────────────────────────────
const sleep = ms => new Promise(r => setTimeout(r, ms));
// ── 串口操作 ──────────────────────────────────────────────────────────────────
function setupPort(dev) {
execSync(
`stty -F "${dev}" ${BAUD} cs8 -cstopb -parenb cread clocal -crtscts raw ` +
`-echo -echoe -echok -echonl min 0 time 5`,
{ stdio: 'ignore' }
);
}
/** 读取串口原始数据,直到 OK/ERROR 或超时 */
async function readRaw(fd, timeoutMs = 2000, stopOnOk = true) {
const buf = Buffer.alloc(4096);
const deadline = Date.now() + timeoutMs;
let text = '';
while (Date.now() < deadline) {
let n = 0;
try {
n = fs.readSync(fd, buf, 0, buf.length, null);
} catch (e) {
if (e.code !== 'EAGAIN' && e.code !== 'EWOULDBLOCK') throw e;
}
if (n > 0) {
text += buf.slice(0, n).toString('utf8');
if (stopOnOk && (/\nOK|\rOK|ERROR/.test(text))) {
// 排尽剩余缓冲
await sleep(120);
try {
while (true) {
const n2 = fs.readSync(fd, buf, 0, buf.length, null);
if (n2 <= 0) break;
text += buf.slice(0, n2).toString('utf8');
}
} catch (_) {}
break;
}
} else {
await sleep(50);
}
}
return text;
}
/** 发送 AT 命令,返回清理后的响应行数组 */
async function atCmd(fd, command, waitMs = 2000) {
// 清空输入缓冲
try { while (fs.readSync(fd, Buffer.alloc(256), 0, 256, null) > 0) {} } catch (_) {}
fs.writeSync(fd, Buffer.from(command + '\r'));
const raw = await readRaw(fd, waitMs, true);
return cleanLines(raw);
}
function cleanLines(raw) {
return raw
.replace(/\r/g, '\n')
.split('\n')
.map(l => l.trim())
.filter(l => {
if (!l) return false;
if (l === 'OK' || l === 'ERROR') return false;
if (l.startsWith('AT')) return false;
const printable = [...l].filter(c => c >= ' ').length;
return printable / Math.max(1, l.length) >= 0.85;
});
}
// ── 端口发现 ──────────────────────────────────────────────────────────────────
function discoverPorts() {
if (!fs.existsSync(BY_PATH_DIR)) return [];
return fs.readdirSync(BY_PATH_DIR)
.filter(n => n.endsWith(TARGET_SUFFIX))
.sort()
.map(n => {
const link = path.join(BY_PATH_DIR, n);
try {
const real = fs.realpathSync(link);
return fs.existsSync(real) ? { link, real } : null;
} catch (_) { return null; }
})
.filter(Boolean);
}
// ── SMS 解析 ──────────────────────────────────────────────────────────────────
/** 尝试将纯 UCS2 hex 解码为中文 */
function maybeDecodeUcs2(text) {
const s = (text || '').replace(/\s+/g, '');
if (!s || s.length % 4 !== 0) return '';
if (/^\d+$/.test(s)) return ''; // 纯数字不解码(验证码原文)
if (!/^[0-9A-Fa-f]+$/.test(s)) return '';
try {
const decoded = Buffer.from(s, 'hex').toString('utf16le');
// utf16le 是 little-endianQuectel 发的是 big-endian需交换字节
const beDecoded = Buffer.from(s, 'hex').swap16().toString('utf16le');
const printable = c => c >= ' ';
const score = s => [...s].filter(printable).length / Math.max(1, s.length);
const best = score(beDecoded) >= score(decoded) ? beDecoded : decoded;
return score(best) >= 0.85 ? best.trim() : '';
} catch (_) { return ''; }
}
/** 从文本提取 4-8 位验证码 */
function extractOtp(text) {
const m = (text || '').match(/(?<!\d)(\d{4,8})(?!\d)/);
return m ? m[1] : '';
}
/** 解析 +CMGL 响应行为消息对象数组 */
function parseSms(lines) {
const messages = [];
let current = null;
for (const line of lines) {
if (line.startsWith('+CMGL:') || line.startsWith('+CMGR:')) {
const parts = line.split(',').map(p => p.trim().replace(/^"|"$/g, ''));
const sender = line.startsWith('+CMGL:')
? (parts[2] || 'UNKNOWN')
: (parts[1] || 'UNKNOWN');
// 时间戳在 +CMGL: idx,stat,sender,,"yy/MM/dd,HH:mm:ss+tz"
const tsRaw = parts[4] || '';
current = { sender, tsRaw, timestamp: parseTimestamp(tsRaw), content: '', decoded: '' };
messages.push(current);
} else if (current && !line.startsWith('+')) {
const raw = (current.content + ' ' + line).trim();
current.content = raw;
current.decoded = maybeDecodeUcs2(raw);
current = null;
}
}
return messages;
}
/** 解析 Quectel 时间戳 "26/04/30,12:34:56+32"+tz 单位 1/4 小时) */
function parseTimestamp(raw) {
if (!raw) return new Date();
const m = raw.match(/(\d{2})\/(\d{2})\/(\d{2}),(\d{2}):(\d{2}):(\d{2})([+-]\d+)?/);
if (!m) return new Date();
const [, yy, mo, dd, hh, mm, ss, tz] = m;
const tzMin = tz ? parseInt(tz) * 15 : 0;
const iso = `20${yy}-${mo}-${dd}T${hh}:${mm}:${ss}${tzOffsetStr(tzMin)}`;
const d = new Date(iso);
return isNaN(d) ? new Date() : d;
}
function tzOffsetStr(totalMin) {
const sign = totalMin >= 0 ? '+' : '-';
const abs = Math.abs(totalMin);
const h = String(Math.floor(abs / 60)).padStart(2, '0');
const m = String(abs % 60).padStart(2, '0');
return `${sign}${h}:${m}`;
}
/** 过滤 N 分钟内的短信 */
function recentOnly(messages, minutes = WINDOW_MINUTES) {
const cutoff = Date.now() - minutes * 60 * 1000;
return messages.filter(m => m.timestamp >= cutoff);
}
// ── 去重 ──────────────────────────────────────────────────────────────────────
function fingerprint(imsi, msg) {
const key = [
imsi || 'UNKNOWN',
msg.sender || 'UNKNOWN',
msg.decoded || msg.content || '',
msg.timestamp.toISOString(),
].join('|');
return crypto.createHash('sha1').update(key).digest('hex');
}
function loadSeen() {
try {
const data = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
return Array.isArray(data) ? data : [];
} catch (_) { return []; }
}
function saveSeen(seen) {
fs.writeFileSync(STATE_FILE, JSON.stringify(seen.slice(-SEEN_LIMIT), null, 2), 'utf8');
}
function filterNew(results, seen) {
const seenSet = new Set(seen);
const newSeen = [...seen];
const filtered = [];
for (const item of results) {
const msgs = [];
for (const msg of item.messages) {
const fp = fingerprint(item.imsi, msg);
if (seenSet.has(fp)) continue;
seenSet.add(fp);
newSeen.push(fp);
msgs.push(msg);
}
filtered.push({ ...item, messages: msgs });
}
return { filtered, newSeen: newSeen.slice(-SEEN_LIMIT) };
}
// ── 读取一个端口 ──────────────────────────────────────────────────────────────
async function readPort({ link, real }) {
let fd;
try {
setupPort(real);
fd = fs.openSync(real, fs.constants.O_RDWR | fs.constants.O_NOCTTY | fs.constants.O_NONBLOCK);
// 排空缓冲
await readRaw(fd, 400, false);
// 获取 IMSI
const cimiLines = await atCmd(fd, 'AT+CIMI');
const imsiMatch = cimiLines.join('\n').match(/\b(\d{15})\b/);
const imsi = imsiMatch ? imsiMatch[1] : 'UNKNOWN';
// 切换文本模式
await atCmd(fd, 'AT+CMGF=1');
// 读取所有短信
const smsLines = await atCmd(fd, 'AT+CMGL="ALL"', 4000);
const messages = recentOnly(parseSms(smsLines));
return { link, device: real, imsi, messages };
} catch (e) {
return { link, device: real, imsi: 'UNKNOWN', messages: [], error: e.message };
} finally {
if (fd !== undefined) try { fs.closeSync(fd); } catch (_) {}
}
}
// ── 输出渲染 ──────────────────────────────────────────────────────────────────
function render(results) {
const now = new Date().toLocaleString('zh-CN', { hour12: false,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
timeZoneName: 'short' });
const lines = [`=== ${now} ===`];
let anySms = false;
for (const item of results) {
const errStr = item.error ? ` ERROR: ${item.error}` : '';
lines.push(`Port: ${item.device} IMSI: ${item.imsi} Messages: ${item.messages.length}${errStr}`);
for (const msg of item.messages) {
anySms = true;
lines.push(`Time: ${msg.timestamp.toLocaleString('zh-CN', { hour12: false })}`);
lines.push(`IMSI: ${item.imsi}`);
lines.push(`From: ${msg.sender}`);
lines.push(`Content: ${msg.decoded || msg.content}`);
const otp = extractOtp(msg.decoded || msg.content);
if (otp) lines.push(`Code: ${otp}`);
lines.push('');
}
}
if (!anySms) lines.push(`No new SMS in last ${WINDOW_MINUTES} minutes.`);
return lines.join('\n').trimEnd() + '\n';
}
// ── 主循环 ────────────────────────────────────────────────────────────────────
async function poll() {
const ports = discoverPorts();
const results = await Promise.all(ports.map(readPort));
for (const item of results) {
for (const msg of item.messages) {
msg.otp = extractOtp(msg.decoded || msg.content);
}
}
const seen = loadSeen();
const { filtered, newSeen } = filterNew(results, seen);
const text = render(filtered);
fs.appendFileSync(OUT_FILE, text, 'utf8');
saveSeen(newSeen);
process.stdout.write(text);
}
async function main() {
console.log(`SIM SMS reader started. Poll every ${POLL_MS / 1000}s → ${OUT_FILE}`);
while (true) {
try {
await poll();
} catch (e) {
console.error('poll error:', e.message);
}
await sleep(POLL_MS);
}
}
main();