diff --git a/drivers/sim/sms-reader.js b/drivers/sim/sms-reader.js new file mode 100644 index 0000000..70b7eb5 --- /dev/null +++ b/drivers/sim/sms-reader.js @@ -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-endian,Quectel 发的是 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(/(? 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();