使用JavaScript处理串口数据(Modbus协议)

Modbus RTU 主站 - 稳定轮询(后台读泵 + 帧队列)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="utf-8" />
    <title>Modbus RTU 主站 - 稳定轮询(后台读泵 + 帧队列)</title>
    <meta name="viewport" content="width=device-width,initial-scale=1" />
    <style>
        body { font-family: Arial, sans-serif; padding: 20px; }
        .config-group { margin-bottom: 12px; }
        .config-group label { display:inline-block; width:140px; }
        .config-group input { width:160px; }
        #connectButton { padding:8px 16px; background:red; color:white; border:none; cursor:pointer; }
        #connectButton.connected { background:green; }
        #connectionInfo { font-size: small; color: gray; }
        #dataDisplay { margin-top:16px; padding:12px; border:1px solid #ccc; border-radius:6px; font-family:monospace; white-space:pre-wrap; display: flex; justify-content: space-between; }
        #dataDisplay > div { flex: 1; padding: 0 10px; }
        #dataDisplay > div:first-child { border-right: 1px solid #ccc; }
        #counters { margin-left: 12px; font-size: 13px; color: #333; }
        #counters span { font-weight: bold; margin-right: 8px; }
        #toolbar { margin-top: 8px; }
        #resetCountersBtn { margin-left: 8px; }
    </style>
</head>
<body>
    <h3>Modbus RTU 主站 — 稳定轮询(后台读泵 + 帧队列)</h3>

    <div class="config-group">
        <label>波特率:</label><input id="baudRateInput" type="number" value="115200">
    </div>
    <div class="config-group">
        <label>从机地址:</label><input id="slaveAddressInput" type="number" value="1">
    </div>
    <div class="config-group">
        <label>间隔时间 (ms):</label><input id="intervalInput" type="number" value="1">
    </div>
    <div class="config-group">
        <label>数据长度 (寄存器数):</label><input id="numRegsInput" type="number" value="10">
    </div>
    <div class="config-group">
        <label>超时时间 (ms):</label><input id="timeoutInput" type="number" value="2000">
    </div>

    <div class="config-group">
        <label>写保持寄存器地址:</label><input id="writeAddressInput" type="number" value="0">
    </div>
    <div class="config-group">
        <label>写保持寄存器值:</label><input id="writeValueInput" type="number" value="0">
    </div>

    <div id="toolbar">
        <button id="connectButton" onclick="toggleConnection()">连接设备</button>
        <span id="connectionInfo"></span>
        <span id="counters">发送: <span id="sendCount">0</span> 失败: <span id="failCount">0</span></span>
        <button id="resetCountersBtn" onclick="resetCounters()">重置计数</button>
    </div>

    <div style="margin-top:8px;">
        <button id="writeButton" onclick="setPendingWrite()" disabled>写保持寄存器 (0x06)</button>
    </div>

    <div id="dataDisplay">无数据</div>

<script>
/*
  后台读泵 + 帧队列 说明(要点)
  - readPump(): 持续读取串口字节,追加到 bufferBytes
  - parseBufferToFrames(): 尝试从 bufferBytes 中提取完整 Modbus 帧(优先按 func 判断长度;idle fallback)
  - framesQueue: 已解析但未消费的完整帧,sendAndWait() 从中匹配期望的响应
  - sendAndWait(): 发送数据,等待 framesQueue 中匹配的响应(按 slave & func)
  - CRC 校验在解析阶段进行,避免噪声或半帧误判
*/

let port = null;
let isConnected = false;
let baudRate = 115200;
let slaveAddress = 1;
let intervalMs = 500;
let numRegs = 10;
let timeoutMs = 500;

// 串口读泵/解析状态
let activeReader = null;
let readPumpTask = null;
let bufferBytes = new Uint8Array(0); // 未解析的字节缓存
let framesQueue = []; // { frame: Uint8Array, ts: number }

// 写控制
let activeWriter = null;
let pendingWrite = false;
let pendingWriteAddress = 0;
let pendingWriteValue = 0;

// 计数器
let sendCount = 0;
let failCount = 0;

// DOM
const connectBtn = document.getElementById('connectButton');
const connInfo = document.getElementById('connectionInfo');
const dataDisplay = document.getElementById('dataDisplay');
const writeBtn = document.getElementById('writeButton');
const sendCountEl = document.getElementById('sendCount');
const failCountEl = document.getElementById('failCount');

let lastInputData = '无数据';
let lastHoldingData = '无数据';

// CRC16 (Modbus)
function calculateCRC(bytes) {
    let crc = 0xFFFF;
    for (let i = 0; i < bytes.length; i++) {
        crc ^= bytes[i];
        for (let j = 0; j < 8; j++) {
            if (crc & 0x0001) crc = (crc >> 1) ^ 0xA001;
            else crc >>= 1;
        }
    }
    return crc;
}
function checkCRC(frame) {
    if (!frame || frame.length < 3) return false;
    const len = frame.length;
    const crcIn = (frame[len-1] << 8) | frame[len-2];
    const crcCalc = calculateCRC(frame.slice(0, len-2));
    return crcIn === crcCalc;
}
function toHex(arr) {
    return Array.from(arr).map(b => b.toString(16).padStart(2,'0')).join(' ');
}

// 字符时间与 idle 计算(经验)
function charTimeMs(baud) {
    return 10000 / Math.max(1, baud); // ms for 1 char (10 bits)
}
function computeIdleMs(baud) {
    // Modbus RTU 标准 3.5 字符时间,但浏览器实现更粗糙,取 max(8, 3.5char)
    const t = Math.ceil(3.5 * charTimeMs(baud));
    return Math.max(8, t);
}
function computePostWriteDelayMs(baud) {
    // 写入后等候至少 1 字符时间(加余量)
    return Math.max(2, Math.ceil(charTimeMs(baud)));
}

function sleep(ms) { return new Promise(r=>setTimeout(r, ms)); }
function appendLog(text) { console.log(`[${new Date().toLocaleTimeString()}] ${text}`); }

// 更新计数器显示
function updateCounters() {
    sendCountEl.textContent = String(sendCount);
    failCountEl.textContent = String(failCount);
}
function resetCounters() {
    sendCount = 0;
    failCount = 0;
    updateCounters();
}

// safe writer:写并短等
async function sendRaw(data) {
    if (!isConnected || !port || !port.writable) throw new Error('未连接或端口不可写');
    try {
        activeWriter = port.writable.getWriter();
        await activeWriter.write(data);
        const postDelay = computePostWriteDelayMs(baudRate);
        if (postDelay > 0) await sleep(postDelay);
    } finally {
        if (activeWriter) {
            try { activeWriter.releaseLock(); } catch(e){}
            activeWriter = null;
        }
    }
}

// 把新字节追加到 bufferBytes
function appendToBuffer(chunk) {
    if (!chunk || chunk.length === 0) return;
    const nb = new Uint8Array(bufferBytes.length + chunk.length);
    nb.set(bufferBytes, 0);
    nb.set(chunk, bufferBytes.length);
    bufferBytes = nb;
}

// 从 bufferBytes 中按 Modbus 规则提取尽可能多的完整帧,放入 framesQueue
function parseBufferToFrames() {
    let madeProgress = false;
    while (bufferBytes.length >= 5) {
        const b0 = bufferBytes[0];
        const func = bufferBytes[1];
        let expectedLen = null;
        if (func === 3 || func === 4) {
            if (bufferBytes.length >= 3) {
                const byteCount = bufferBytes[2];
                expectedLen = 3 + byteCount + 2;
            } else {
                break;
            }
        } else if (func === 6 || func === 0x10) {
            expectedLen = 8;
        } else if (func & 0x80) {
            expectedLen = 5;
        } else {
            expectedLen = null;
        }

        if (expectedLen !== null) {
            if (bufferBytes.length < expectedLen) break;
            const candidate = bufferBytes.slice(0, expectedLen);
            if (checkCRC(candidate)) {
                framesQueue.push({ frame: candidate, ts: Date.now() });
                bufferBytes = bufferBytes.slice(expectedLen);
                madeProgress = true;
                continue;
            } else {
                let found = false;
                for (let i = 1; i < Math.min(16, bufferBytes.length - 4); i++) {
                    const func2 = bufferBytes[i+1];
                    let exp2 = null;
                    if (func2 === 3 || func2 === 4) {
                        if (bufferBytes.length >= i+3) {
                            const bc = bufferBytes[i+2];
                            exp2 = 3 + bc + 2;
                        } else { exp2 = null; }
                    } else if (func2 === 6 || func2 === 0x10) exp2 = 8;
                    else if (func2 & 0x80) exp2 = 5;

                    if (exp2 !== null && bufferBytes.length >= i + exp2) {
                        const cand = bufferBytes.slice(i, i + exp2);
                        if (checkCRC(cand)) {
                            bufferBytes = bufferBytes.slice(i);
                            found = true;
                            break;
                        }
                    }
                }
                if (!found) {
                    bufferBytes = bufferBytes.slice(1);
                }
                madeProgress = true;
                continue;
            }
        } else {
            break;
        }
    }
    return madeProgress;
}

function tryParseByScanning() {
    if (bufferBytes.length < 5) return false;
    for (let L = 5; L <= bufferBytes.length; L++) {
        const cand = bufferBytes.slice(0, L);
        if (checkCRC(cand)) {
            framesQueue.push({ frame: cand, ts: Date.now() });
            bufferBytes = bufferBytes.slice(L);
            return true;
        }
    }
    for (let i = 1; i <= Math.min(32, bufferBytes.length - 5); i++) {
        for (let L = 5; L <= bufferBytes.length - i; L++) {
            const cand = bufferBytes.slice(i, i + L);
            if (checkCRC(cand)) {
                bufferBytes = bufferBytes.slice(i + L);
                framesQueue.push({ frame: cand, ts: Date.now() });
                return true;
            }
        }
    }
    return false;
}

// 后台读泵主函数(长期运行)
async function readPump() {
    if (!port || !port.readable) return;
    appendLog('readPump 启动');
    activeReader = port.readable.getReader();
    let lastReceiveTime = 0;
    const idleMs = computeIdleMs(baudRate);
    try {
        while (isConnected) {
            let result;
            try {
                result = await activeReader.read();
            } catch (err) {
                appendLog('readPump read() 抛错: ' + (err.message || err));
                break;
            }
            if (!result) break;
            const { value, done } = result;
            if (done) {
                appendLog('readPump: stream done');
                break;
            }
            if (value && value.length) {
                appendLog('readPump 收到 ' + value.length + ' 字节: ' + toHex(value));
                appendToBuffer(value);
                lastReceiveTime = Date.now();
                parseBufferToFrames();
            }
            await sleep(0);
            if (Date.now() - lastReceiveTime >= idleMs && bufferBytes.length >= 5) {
                let got = true;
                while (got) {
                    got = parseBufferToFrames() || tryParseByScanning();
                }
            }
        }
    } finally {
        try { await activeReader.cancel(); } catch(e) {}
        try { activeReader.releaseLock(); } catch(e) {}
        activeReader = null;
        appendLog('readPump 退出');
    }
}

// 等待 framesQueue 中匹配某 (slave, funcOptions) 的响应,超时返回 null
async function waitForResponse(expectedSlave, funcOptions, overallTimeoutMs = 500) {
    const start = Date.now();
    if (!Array.isArray(funcOptions)) funcOptions = [funcOptions];

    while (Date.now() - start < overallTimeoutMs) {
        for (let i = 0; i < framesQueue.length; i++) {
            const { frame } = framesQueue[i];
            if (!frame || frame.length < 2) continue;
            if (frame[0] !== expectedSlave) continue;
            const f = frame[1];
            if (funcOptions.includes(f)) {
                framesQueue.splice(i, 1);
                return frame;
            }
            if ((f & 0x80) && funcOptions.some(e => (e & 0x80))) {
                framesQueue.splice(i, 1);
                return frame;
            }
        }
        await sleep(6);
    }
    return null;
}

// 高级封装:发送并等待指定 func 响应(增加发送/失败计数)
async function sendAndWait(frameToSend, wantFuncs, waitMs) {
    // 发送计数
    sendCount++;
    updateCounters();

    try {
        await sendRaw(frameToSend);
    } catch (err) {
        appendix:
        appendLog('sendAndWait 发送失败: ' + (err.message || err));
        // 发送失败算作一次失败
        failCount++;
        updateCounters();
        return null;
    }
    const resp = await waitForResponse(slaveAddress, wantFuncs, waitMs);
    if (!resp) {
        appendLog('sendAndWait: 等待响应超时');
        failCount++;
        updateCounters();
    } else {
        appendLog('sendAndWait 收到响应: ' + toHex(resp));
    }
    return resp;
}

// 解析 Modbus 0x03/0x04 响应生成寄存器数组(带 CRC 已验证)
function parseReadResponseBuffer(buf, funcCode) {
    if (!buf || buf.length < 5) return null;
    if (buf[0] !== slaveAddress) return null;
    if (buf[1] !== funcCode) {
        return null;
    }
    const byteCount = buf[2];
    if (buf.length < 3 + byteCount + 2) return null;
    const regCount = Math.floor(byteCount / 2);
    const regs = new Uint16Array(regCount);
    for (let i = 0; i < regCount; i++) {
        const off = 3 + i*2;
        regs[i] = (buf[off] << 8) | buf[off+1];
    }
    return regs;
}
function parseWriteResponse(buf) {
    if (!buf || buf.length < 8) return false;
    if (buf[0] !== slaveAddress) return false;
    if (buf[1] !== 0x06) return false;
    return true;
}

// UI 更新
function updateDisplay() {
    dataDisplay.innerHTML = `<div>${lastInputData.replace(/\n/g, '<br>')}</div><div>${lastHoldingData.replace(/\n/g, '<br>')}</div>`;
}
function updateInputData(regs) {
    if (!regs) { lastInputData = '无或不完整的数据 (0x04)'; updateDisplay(); return; }
    let txt = '输入寄存器 (0x04):\n';
    for (let i = 0; i < regs.length; i++) txt += `寄存器 ${i}: ${regs[i]}\n`;
    lastInputData = txt; updateDisplay();
}
function updateHoldingData(regs) {
    if (!regs) { lastHoldingData = '无或不完整的数据 (0x03)'; updateDisplay(); return; }
    let txt = '保持寄存器 (0x03):\n';
    for (let i = 0; i < regs.length; i++) txt += `寄存器 ${i}: ${regs[i]}\n`;
    lastHoldingData = txt; updateDisplay();
}

// 设置待写任务
function setPendingWrite() {
    if (!isConnected) { appendLog('无法设置写任务:未连接'); return; }
    pendingWriteAddress = parseInt(document.getElementById('writeAddressInput').value) || 0;
    pendingWriteValue = parseInt(document.getElementById('writeValueInput').value) || 0;
    pendingWrite = true;
    writeBtn.disabled = true;
    appendLog(`设置待写: 地址 ${pendingWriteAddress}, 值 ${pendingWriteValue}`);
}

// 轮询主逻辑(串行):使用 sendAndWait 等待响应(更稳)
let pollingTask = null;
async function startPolling() {
    if (pollingTask) return;
    pollingTask = (async () => {
        appendLog('轮询开始');
        while (isConnected) {
            try {
                const start = 0;
                const startHi = (start >> 8) & 0xFF;
                const startLo = start & 0xFF;
                const numHi = (numRegs >> 8) & 0xFF;
                const numLo = numRegs & 0xFF;
                const frame04 = buildModbusFrame(slaveAddress, 0x04, [startHi, startLo, numHi, numLo]);

                const resp04 = await sendAndWait(frame04, [0x04, 0x84], timeoutMs);
                if (resp04) {
                    const regs = parseReadResponseBuffer(resp04, 0x04);
                    updateInputData(regs);
                } else {
                    appendLog('0x04 未收到或超时');
                }

                const frame03 = buildModbusFrame(slaveAddress, 0x03, [startHi, startLo, numHi, numLo]);
                const resp03 = await sendAndWait(frame03, [0x03, 0x83], timeoutMs);
                if (resp03) {
                    const regs03 = parseReadResponseBuffer(resp03, 0x03);
                    updateHoldingData(regs03);
                } else {
                    appendLog('0x03 未收到或超时');
                }

                if (pendingWrite) {
                    const addrHi = (pendingWriteAddress >> 8) & 0xFF;
                    const addrLo = pendingWriteAddress & 0xFF;
                    const valHi = (pendingWriteValue >> 8) & 0xFF;
                    const valLo = pendingWriteValue & 0xFF;
                    const writeFrame = buildModbusFrame(slaveAddress, 0x06, [addrHi, addrLo, valHi, valLo]);
                    const wResp = await sendAndWait(writeFrame, [0x06, 0x86], timeoutMs);
                    if (wResp && parseWriteResponse(wResp)) {
                        appendLog(`写成功 地址 ${pendingWriteAddress} 值 ${pendingWriteValue}`);
                    } else {
                        appendLog('写保持寄存器失败或超时');
                    }
                    pendingWrite = false;
                    writeBtn.disabled = false;
                }

            } catch (err) {
                appendLog('轮询内部错误: ' + (err.message || err));
            }
            await sleep(Math.max(1, intervalMs));
        }
        appendLog('轮询结束');
        pollingTask = null;
    })();
}

// build frame
function buildModbusFrame(slaveAddr, funcCode, data) {
    const frame = [slaveAddr, funcCode, ...data];
    const crc = calculateCRC(frame);
    frame.push(crc & 0xFF);
    frame.push((crc >> 8) & 0xFF);
    return new Uint8Array(frame);
}

// 连接 / 断开
async function toggleConnection() {
    connectBtn.disabled = true;
    if (isConnected) {
        await closePort();
        connectBtn.disabled = false;
        return;
    }

    baudRate = parseInt(document.getElementById('baudRateInput').value) || 115200;
    slaveAddress = parseInt(document.getElementById('slaveAddressInput').value) || 1;
    intervalMs = parseInt(document.getElementById('intervalInput').value) || 500;
    numRegs = parseInt(document.getElementById('numRegsInput').value) || 10;
    timeoutMs = parseInt(document.getElementById('timeoutInput').value) || 500;

    try {
        port = await navigator.serial.requestPort({ filters: [] });
        await port.open({
            baudRate: baudRate,
            dataBits: 8,
            stopBits: 1,
            parity: 'none',
            bufferSize: 1024,
            flowControl: 'none'
        });

        isConnected = true;
        connectBtn.textContent = '断开连接';
        connectBtn.classList.add('connected');
        connInfo.textContent = `已连接: ${baudRate}-8-N-1, 从机地址: ${slaveAddress}`;
        appendLog('串口已打开');

        writeBtn.disabled = false;

        // 启动读泵
        readPumpTask = readPump();

        // 启动轮询
        startPolling();
    } catch (err) {
        appendLog('连接失败: ' + (err.message || err));
        alert('连接串口失败: ' + (err.message || err));
        isConnected = false;
        connectBtn.textContent = '连接设备';
        connectBtn.classList.remove('connected');
    } finally {
        connectBtn.disabled = false;
    }
}

// 关闭端口(安全)
async function closePort() {
    appendLog('开始断开');
    try {
        isConnected = false;

        if (activeReader) {
            try { await activeReader.cancel(); } catch(e) {}
            try { activeReader.releaseLock(); } catch(e) {}
            activeReader = null;
        }
        const giveUp = Date.now() + 400;
        while (readPumpTask && Date.now() < giveUp) {
            await sleep(20);
        }

        if (port) {
            try { await port.close(); } catch(e) { appendLog('port.close 错: ' + (e.message||e)); }
            port = null;
        }
    } finally {
        isConnected = false;
        bufferBytes = new Uint8Array(0);
        framesQueue = [];
        pendingWrite = false;
        connectBtn.textContent = '连接设备';
        connectBtn.classList.remove('connected');
        connInfo.textContent = '';
        lastInputData = '无数据';
        lastHoldingData = '无数据';
        updateDisplay();
        writeBtn.disabled = true;
        appendLog('断开完成');
    }
}

// 页面卸载时确保断开
window.addEventListener('beforeunload', async (e) => {
    if (isConnected) {
        try { await closePort(); } catch(e) {}
    }
});

// init
function resetDisplay() {
    lastInputData = '无数据';
    lastHoldingData = '无数据';
    updateDisplay();
}
resetDisplay();
updateCounters();

window.toggleConnection = toggleConnection;
window.setPendingWrite = setPendingWrite;
window.resetCounters = resetCounters;

</script>
</body>
</html>
View Code

ScreenGif

 

posted @ 2025-02-18 11:03  阿坦  阅读(132)  评论(0)    收藏  举报