Skip to content
On this page

在web端使用webSerial实现串口通信

参考资料

Web Serial API,在web端使用串口通信

Web Serial API 文档

使用React和Web Serial API 开发的串口调试工具

简介

串口是一个双向通信接口,允许字节发送和接收数据。

Web Serial API为网站提供了一种使用JavaScript对串行设备进行读写的方法。串行设备可以通过用户系统上的串行端口连接,也可以通过模拟串行端口的可移动USB和蓝牙设备连接。

换句话说,Web Serial API通过允许网站与串行设备(如微控制器和3D打印机)通信来连接网络和物理世界。

这个API也是WebUSB的好伙伴,因为操作系统要求应用程序使用它们的高级串行API而不是低级的USB API与一些串行端口通信。

检查浏览器是否支持Web Serial API

javascript
if ("serial" in navigator) {
  // The Web Serial API is supported.
}

在这里插入图片描述

打开串口

Web Serial API在设计上是异步的。这可以防止网站UI在等待输入时阻塞,这一点很重要,因为串行数据可以在任何时候接收,需要一种方法来侦听它。要打开串口,首先访问一个SerialPort对象。为此,您可以通过调用navigator.serial.requestPort()来提示用户选择一个串行端口,或者从navigator.serial.getPorts()中选择一个,该方法返回一个先前授予该网站访问权限的串行端口列表。

javascript
// 提示用户选择一个串口。
const port = await navigator.serial.requestPort();

// 获取用户之前授予该网站访问权限的所有串口。
const ports = await navigator.serial.getPorts();

您还可以在打开串行端口时指定下面的任何选项。这些选项是可选的,并且有方便的默认值。

  • dataBits:每帧的数据位数(7或8)。
  • stopBits:一帧结束时的停止位数(1或2)。
  • parity:校验模式,可以是none,偶数,奇数。
  • bufferSize:应该创建的读写缓冲区大小(必须小于16MB)。
  • flowControl:流控模式(none或hardware)。

串口中读取数据

Web Serial API中的输入和输出流由streams API处理。

串口连接建立之后,SerialPort对象的readablewritable属性返回一个ReadableStream和一个WritableStream。这些将用于从串行设备接收数据并将数据发送到串行设备。两者都使用Uint8Array实例进行数据传输。

当新数据从串行设备到达时,port.readable.getReader().read()异步返回两个属性:value和一个done的布尔值。如果done为真,则串行端口已经关闭,或者没有更多的数据输入。调用port.readable.getReader()创建一个读取器并将其锁定为readable。当可读被锁定时,串口不能被关闭。

javascript
const reader = port.readable.getReader();

// 监听来自串行设备的数据
while (true) {
  const { value, done } = await reader.read();
  if (done) {
    // 允许稍后关闭串口。
    reader.releaseLock();
    break;
  }
  // value 是一个 Uint8Array
  console.log(value);
}

如果串行设备发送文本返回,您可以管道端口。可通过TextDecoderStream读取,如下所示。TextDecoderStream是一个转换流,抓取所有的Uint8Array块并将其转换为字符串。

javascript
const textDecoder = new TextDecoderStream();
const readableStreamClosed = port.readable.pipeTo(textDecoder.writable);
const reader = textDecoder.readable.getReader();

// 监听来自串行设备的数据。
while (true) {
  const { value, done } = await reader.read();
  if (done) {
    // 允许稍后关闭串口。
    reader.releaseLock();
    break;
  }
  // value 是一个 string.
  console.log(value);
}

写入串口

要将数据发送到串行设备,请将数据传递到port.writable.getWriter().write()。在port.writable. getwriter()上调用releaseLock()是为了稍后关闭串口。

javascript
const writer = port.writable.getWriter();

const data = new Uint8Array([104, 101, 108, 108, 111]); // hello
await writer.write(data);


// 允许稍后关闭串口。
writer.releaseLock();

通过管道传输到端口的TextEncoderStream向设备发送文本。port.writable如下所示。

javascript
const textEncoder = new TextEncoderStream();
const writableStreamClosed = textEncoder.readable.pipeTo(port.writable);

const writer = textEncoder.writable.getWriter();

await writer.write("hello");

关闭串口

port.close()如果串行端口的readablewritable被解锁,则关闭该串行端口,这意味着已经为其各自的读写成员调用了releaseLock()

但是,当使用循环从串行设备连续读取数据时,端口Readable将一直被锁定,直到遇到错误。在这种情况下,调用reader.cancel()将强制reader.read()立即解析为{value: undefined, done: true},从而允许循环调用reader.releaseLock()

javascript
await state.reader.cancel()
await state.writer.close()
await state.port.close()

监听串口设备的插入和拔出

如果一个串行端口是由USB设备提供的,那么该设备可以从系统连接或断开。当网站被授予访问串口的权限时,它应该监控连接和断开事件。

javascript
navigator.serial.addEventListener("connect", (event) => {
  // TODO: 自动打开事件。目标器或警告用户端口可用。
});

navigator.serial.addEventListener("disconnect", (event) => {
  // TODO: Remove |event.target| from the UI.
  // 如果打开了串行端口,还会观察到流错误。
});

项目开发串口通信代码

javascript
import { buf2hex, hexCharCodeToStr, decToHex } from "../utils/utils"
import { message } from "ant-design-vue"
import inputObject from "../utils/config"
import { takingPictures } from "../../api/collect"

const serialPort = {
    state: {
        step: 0, // 步骤值
        inputObject,
        writer: null,
        reader: null,

        port: null,// 连接的串口
        isConnect: false, // 是否连接
        equipmentInfo: '', // 设备信息
        dataSource: [],//源数据
        code: '', // 存储接收到的数据的指令
        sendData: [], // 发送的数据
        receiveData: [], // 接收的数据
        hexMessage: "",// 接收到的完整16进制字符串
        checkResults: false, // 检验结果
        isAwait: false, // 是否处于等待接收数据状态(断包时记录该状态)
        timerOut: null, // 数据超时无响应
        support: false, // 是否支持navigator.serial 串口通讯
        isStop: false, // 机器人是否处于暂停状态
        orderInfo: {//选择的订单信息
            id: "",//订单id
            sn: "",//订单号
            size: "",//码数
        },
        subOrder: [],//创建的子订单
    },

    mutations: {
        // 监听串口的连接的断开
        listenersSerialPort (state) {
            navigator.serial.addEventListener('connect', (event) => {
                message.success("串口设备已插入")
                // TODO: Automatically open event.target or warn user a port is available.
            })

            navigator.serial.addEventListener('disconnect', (event) => {
                // state.isConnect = true
                message.error("串口设备已拔出")
                // console.log('串口已断开')
                // TODO: Remove |event.target| from the UI.
                // If the serial port was opened, a stream error would be observed as well.
            })
        },
        // 移除监听
        removeListenersSerialPort () {
            navigator.serial.removeEventListener('connect', (event) => {
                message.success("串口设备已插入")
                // TODO: Automatically open event.target or warn user a port is available.
            })

            navigator.serial.removeEventListener('disconnect', (event) => {
                // state.isConnect = true
                message.error("串口设备已拔出")
                // console.log('串口已断开')
                // TODO: Remove |event.target| from the UI.
                // If the serial port was opened, a stream error would be observed as well.
            })
        },

        // 修改浏览器是否支持串口通讯的标识
        updateSupport (state, flag) {
            state.support = flag
        },
        // 添加表格数据
        addDataSource (state, data) {
            state.dataSource = []
            data.forEach((item, index) => {
                item.step = index + 1
                item.key = index + 1
            })
            state.dataSource = data
            console.log("dataSource", state.dataSource)
        },
        // 删除表格数据
        deleteDataSource (state, key) {
            let index = state.dataSource.findIndex((item) => {
                return item.key == key
            })
            let list = state.dataSource
            list.splice(index, 1)
            state.dataSource = []
            state.dataSource.push(...list)
        },
        // 修改表格数据
        modifyDataSource (state, obj) {
            let { index, data } = obj
            let list = state.dataSource
            list.splice(index, 1, data)
            state.dataSource = []
            state.dataSource.push(...list)
        },
        // 接收数据
        receiveDataFuc (state, value) {
            console.log('value', value)
            // value 是一个 Uint8Array
            let hexStr = buf2hex(value)
            let msg = ''
            this.commit('dataValidation', hexStr)
            console.log('isAwait', state.isAwait)
            if (state.isAwait) {
                return
            }
            if (!state.checkResults) {
                state.sendData = []
                state.receiveData = []
                console.log('进入了!this.checkResults')
                if (state.code == '0c') {
                    this.dispatch('submitData')
                } else {
                    message.error("指令执行失败,请尝试从新发送")
                }
                return
            }

            msg = state.hexMessage.slice(6, hexStr.length - 4)
            console.log('指令类型', state.code)
            console.log('返回的信息', msg)

            switch (state.code) {
                case '06': // 获取设备信息
                    let info = hexCharCodeToStr(msg)
                    state.equipmentInfo = info
                    let arr = info.split(' ')
                    state.equipmentInfo = arr.join(" ")
                    console.log('arr', arr, state.equipmentInfo)
                    state.receiveData = []
                    break
                case '0a':// 接收霍尔传感器数据
                    console.log("霍尔数据", msg)
                    // 偶数圈发送拍摄型号
                    // 当前圈数等于总圈数发送结束信号
                    let data1 = msg.slice(0, 2)
                    let data2 = msg.slice(2, 4)
                    let data3 = msg.slice(4, 6)
                    let data4 = msg.slice(6, msg.length)
                    let turns = parseInt(data1, 16) * 256 + parseInt(data2, 16)
                    let turnsCount = parseInt(data3, 16) * 256 + parseInt(data4, 16)
                    console.log("当前圈数", turns, '总圈数', turnsCount)
                    state.receiveData = []
                    this.dispatch("takingPicturesFunc", { turns, turnsCount })

                    break;
                case '10': // 复位机器人
                    message.success("机器人已经复位")
                    state.receiveData = []
                    state.isStop = true
                    break;
                case '11': // 机器人开始工作
                    message.success("机器人已经开始工作")
                    state.receiveData = []
                    state.isStop = false
                    break;
                case '12': // 机器人暂停工作
                    message.success("机器人已经暂停工作")
                    state.receiveData = []
                    state.isStop = true
                    break;
                case '0c': // 收到的形变数据
                    this.checkResults = false
                    state.sendData = []
                    state.receiveData = []
                    state.step = state.step + 1
                    if (state.step <= state.dataSource.length - 1) {
                        this.dispatch('submitData')
                    } else {
                        message.success("数据传输完毕")
                    }
                    break
            }
        },
        // 数据校验
        dataValidation (state, hexStr) {
            clearTimeout(state.timerOut)
            state.timerOut = null
            let startStr = hexStr.slice(0, 2)
            let endStr = hexStr.slice(hexStr.length - 2, hexStr.length)
            if (startStr == '55' && (endStr == '0a' || endStr == '0d') && hexStr.length > 4) {
                this.commit("lengthValidation", hexStr)
            } else if (startStr == '55' && endStr != '0a' && endStr != '0d') {
                console.log('帧头部分')
                state.isAwait = true
                state.receiveData.push(hexStr)
            } else if (
                startStr != '55' &&
                endStr != '0a' && endStr != '0d' &&
                state.receiveData.length > 0
            ) {
                console.log('收到中间部分')
                state.receiveData.push(hexStr)
            } else {
                console.log('收到帧尾部分')
                if (state.receiveData.length > 0) {
                    state.receiveData.push(hexStr)
                    let receiveDataStr = state.receiveData.join('')
                    this.commit("lengthValidation", receiveDataStr)
                }
            }
        },
        // 数据长度校验
        lengthValidation (state, hexStr) {
            console.log("hexStr", hexStr, hexStr.length)
            state.code = hexStr.slice(2, 4)
            let receiveDataLength = 0
            switch (state.code) {
                case '06': // 获取设备信息
                    console.log("设备信息长度校验")
                    receiveDataLength = 31 * 2
                    break
                case '0a':// 接收霍尔传感器数据
                    console.log("霍尔数据长度校验")
                    receiveDataLength = 9 * 2
                    break;
                case '10': // 复位机器人
                    console.log("复位机器人长度校验")
                    receiveDataLength = 5 * 2
                    break;
                case '11': // 机器人开始工作
                    console.log("机器人开始工作长度校验")
                    receiveDataLength = 5 * 2
                    break;
                case '12': // 机器人暂停工作
                    console.log("机器人暂停工作长度校验")
                    receiveDataLength = 5 * 2
                    break;
                case '0c': // 收到的形变数据
                    console.log("形变数据长度校验")
                    receiveDataLength = state.sendData.length * 2
                    break;
            }
            console.log(hexStr.length, receiveDataLength)
            hexStr.length == receiveDataLength
                ? (state.checkResults = true)
                : (state.checkResults = false)
            state.hexMessage = hexStr
            state.isAwait = false
        },
        // 表格数据重置
        dataReset (state) {
            state.dataSource = []
            state.step = 0
        },
        // 更新选择的订单号
        setOrderInfo (state, orderInfo) {
            console.log("orderInfo", orderInfo)
            state.orderInfo = Object.assign({}, state.orderInfo, orderInfo)
        },
        // 更新子订单列表
        setSubOrder (state, subOrder) {
            state.subOrder = subOrder
        }
    },

    actions: {
        // 连接(关闭)串口
        async connect ({ state }) {
            if (!state.support) {
                message.error(state.errorMsg)
                return
            }
            if (state.isConnect) {
                await state.reader.cancel()
                await state.writer.close()
                await state.port.close()
                state.isConnect = false
                message.success("已经断开与串口设备的连接")
                return
            }
            // 提示用户选择一个串口
            state.port = await navigator.serial.requestPort()


            try {
                // 等待串口打开  (baudRate:波特率)
                await state.port.open({
                    baudRate: 115200,
                    dataBits: 8, // 数据位
                    stopBits: 1, // 停止位
                    parity: 'none' // 奇偶校验
                })
                state.isConnect = true
                message.success("串口设备已连接")
                // console.log('port', port)

                // 串行写入数据
                state.writer = state.port.writable.getWriter()

                state.reader = state.port.readable.getReader()


                this.dispatch("getEquipmentInfo")

                // console.log('port.readable', port.readable)

                // 监听来自串行设备的数据
                while (true) {
                    const { value, done } = await state.reader.read()
                    this.commit('receiveDataFuc', value)
                    if (done) {
                        // 允许稍后关闭串口。
                        state.reader.releaseLock()
                        break
                    }
                }
            } catch (error) {
                console.log(error)
                message.error("串口打开失败,请检查串口是否已经被占用")
            }

        },
        // 发送数据
        async submitData ({ state }) {
            if (state.dataSource.length == 0) {
                message.error("请添加数据")
                return
            }
            clearTimeout(state.timerOut)
            state.timerOut = null
            //   inputObject数据重置
            for (let dataKey in state.inputObject) {
                state.inputObject[dataKey].sum = 0
            }

            let commonArray = ['0X55', '0X0C', '0X40']
            let stepArray = ['0X00', decToHex(state.step + 1)]
            //   let currentStep =
            //   stepArray.push(currentStep)
            let allStepArray = ['0X00', decToHex(state.dataSource.length)]

            commonArray.push(...[...stepArray, ...allStepArray])

            let inputData = []

            // 将表格的数据与inputData数据对应
            for (let dataKey in state.dataSource[state.step]) {
                state.inputObject[dataKey]
                    ? (state.inputObject[dataKey].sum =
                        state.dataSource[state.step][dataKey])
                    : ''
            }
            //  转换inputObject的数据
            for (let dataKey in state.inputObject) {
                let hex = decToHex(state.inputObject[dataKey].sum)
                inputData.push('0X00', hex)
            }

            commonArray.push(...inputData)

            // 校验和
            let result = 0
            commonArray.forEach((item) => {
                result = result + parseInt(item)
            })
            let resultHex = decToHex(result)
            commonArray.push(resultHex)

            commonArray.push('0X0D') // 结尾

            console.log('commonArray', commonArray)
            state.sendData = []
            state.sendData.push(...commonArray)
            let data = new Uint8Array(commonArray)
            await state.writer.write(data)
        },
        // 获取设备信息
        async getEquipmentInfo ({ state }) {
            console.log('获取设备信息')
            let data = new Uint8Array([
                '0X55',
                '0X05',
                '0X00',
                '0X00',
                '0X5A',
                '0X0D'
            ])
            await state.writer.write(data)
        },
        // 机器人复位
        async robotReset ({ state }) {
            if (!state.isConnect) {
                message.error("请先连接设备")
                return
            }
            console.log("执行机器人复位指令")
            let data = new Uint8Array(['0X55', '0X10', '0X00', '0X00', '0X65', '0x0D'])
            await state.writer.write(data)
        },
        // 机器人开始工作
        async robotStar ({ state }) {
            if (!state.isConnect) {
                message.error("请先连接设备")
                return
            }
            console.log("执行机器人开始工作指令")
            let data = new Uint8Array(['0X55', '0X11', '0X00', '0X00', '0X66', '0x0D'])
            await state.writer.write(data)
        },
        // 机器人暂停工作
        async robotPause ({ state }) {
            if (!state.isConnect) {
                message.error("请先连接设备")
                return
            }
            console.log("执行机器人暂停工作指令")
            let data = new Uint8Array(['0X55', '0X12', '0X00', '0X00', '0X67', '0x0D'])
            await state.writer.write(data)
        },
        // 拍摄照片
        takingPicturesFunc ({ state }, turnNumber) {
            let { turns, turnsCount } = turnNumber
            let action = turns % 2 == 0 ? 'start' : 'stop'
            let index = turns % 2 == 0 ? turns - 2 : turns - 1
            this.dispatch("takingPicturesRequest", { action, index })
        },
        // 拍照的请求
        // 
        takingPicturesRequest ({ state }, { action, index }) {
            takingPictures({
                action,
                ucode: state.subOrder[index].ordersn,
                ordersn: state.orderInfo.sn
            }).then(res => {
                console.log('拍照', res)
                if (res == "Not Found") {
                    this.dispatch("takingPicturesRequest", { action, index })
                } else {
                    console.log(`相机${action}`)
                }
                // if(res.data.)
            }).catch(error => {
                console.log("拍照错误", error)
            })
        }
    },
    getters: {
        // 获取表格数据
        getDataSource: state => state.dataSource,
        // 获取inputObject
        getInputObject: state => state.inputObject,
        // 获取连接状态
        getIsConnect: state => state.isConnect,
        // 获取设备信息
        getEquipmentInfo: state => state.equipmentInfo,
        // 获取步骤值
        getStep: state => state.step,
        // 获取选择的订单信息
        getOrderInfo: state => state.orderInfo
    }
}

export default serialPort

javascript数据转换相关函数

javascript
// 将Uint8Array转为十六进制
export function buf2hex (buffer) {
    return Array.prototype.map
        .call(new Uint8Array(buffer), (x) => ('00' + x.toString(16)).slice(-2))
        .join('')
}

// 将Uint8Array转为十六进制
export function Decoding8arr (uint8array) {
    return new TextDecoder('utf-8').decode(uint8array)
}

// 十六进制字符串转字符串
export function hexCharCodeToStr (hexCharCodeStr) {
    var trimmedStr = hexCharCodeStr.trim()
    var rawStr =
        trimmedStr.substr(0, 2).toLowerCase() === '0x'
            ? trimmedStr.substr(2)
            : trimmedStr
    var len = rawStr.length
    if (len % 2 !== 0) {
        alert('Illegal Format ASCII Code!')
        return ''
    }
    var curCharCode
    var resultStr = []
    for (var i = 0; i < len; i = i + 2) {
        curCharCode = parseInt(rawStr.substr(i, 2), 16) // ASCII Code Value
        resultStr.push(String.fromCharCode(curCharCode))
    }
    return resultStr.join('')
}

// 将十六进制转为 Uint8Array
export function Encoding8arr (myString) {
    return new TextEncoder('utf-8').encode(myString)
}

// 十进制转为带0X前缀的十六进制
export function decToHex (dec) {
    let hex = dec.toString(16)
    let result = '0X'
    hex.length == 1 ? (result = result + '0' + hex) : (result = result + hex)
    return result
}