Java 串口通信(RS232/485)

一.串口通信页面

在这里插入图片描述
Java 实现串口通信,同时通过 WebSocket 与 UI 实时交互传递通信数据

准备工作:

虚拟串口工具:Launch Virtual Serial Port Driver
串口调试助手:SSCOM

在这里插入图片描述

RS485

根据 Modbus 协议,常规485通讯的信息发送形式如下:
地址  功能码 数据信息 校验码
1byte 1byte  nbyte  2byte

在线 CRC检验码计算:CRC 测试链接

二.串口服务实现

1.Java 串口通信配置

1.扩展包和依赖库

RXTXcomm.jar   放入 {JAVA_HOME}/jre/lib/ext
rxtxserial.dll 放入 {JAVA_HOME}/jre/bin

以上两个包可以直接网上下载,注意和JDK版本搭配即可

2.Pom配置

串口通信包:rxtx

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>org.example</groupId>
    <artifactId>SerialPort</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>


    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
            <version>2.7.4</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.24</version>
        </dependency>

        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-websocket</artifactId>
            <version>5.3.27</version>
        </dependency>
        <dependency>
            <groupId>org.rxtx</groupId>
            <artifactId>rxtx</artifactId>
            <version>2.1.7</version>
        </dependency>
    </dependencies>

    <repositories>
        <repository>
            <id>nexus-aliyun</id>
            <name>nexus-aliyun</name>
            <url>http://maven.aliyun.com/nexus/content/groups/public/</url>
            <releases>
                <enabled>true</enabled>
            </releases>
            <snapshots>
                <enabled>false</enabled>
            </snapshots>
        </repository>
    </repositories>

    <pluginRepositories>
        <pluginRepository>
            <id>public</id>
            <name>aliyun nexus</name>
            <url>http://maven.aliyun.com/nexus/content/groups/public/</url>
            <releases>
                <enabled>true</enabled>
            </releases>
            <snapshots>
                <enabled>false</enabled>
            </snapshots>
        </pluginRepository>
    </pluginRepositories>

</project>

2.启动类

package com.serial.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/**
 * @author
 * @date 2023-07-01 12:41
 * @since 1.8
 */
@SpringBootApplication
public class SerialApplication {

    public static void main(String[] args) {
        SpringApplication.run(SerialApplication.class,args);
    }

}

3.工具包类

1.Common

package com.serial.demo.util;

/**
 * @author
 * @date 2023-07-03 22:17
 * @since 1.8
 */
public class Common {

    public static String HEX_STRING = "0123456789ABCDEF";
    public static final String NONE = "无";
    public static final String ODD = "奇";
    public static final String EVEN = "偶";

    public static final String FORMAT_HEX="HEX";
}

2.Crc16Modbus

CRC16 Modbus Java 实现:计算数据的校验码
package com.serial.demo.util;

/**
 * @author
 * @date 2023-07-04 20:37
 * @since 1.8
 */
public class Crc16Modbus {

    /**
     * CRC 循环冗余校验 即通过生成多项式对原始数据进行计算,将计算结果拼接到数据上一起发送
     *     接收方计算接收到的数据校验接收结果是否准确
     * CRC 即对生成多项式的模二运算
     *
     * 1.预置1个16位的寄存器为十六进制 FFFF(即全为1),称此寄存器为CRC寄存器
     * 2.把第1个8位二进制数据(帧头字节)与 CRC 寄存器的低8位相异或并写回寄存器 高8位数据不变
     * 3.把 CRC 循环右移 高位补 0 取得移出位
     * 4.如果移出位为 0 继续右移 如果移出位为 1 则 CRC 寄存器与多项式 A001(1010 0000 0000 0001)进行异或运算
     * 5.重复步骤 3 和 4 直到右移 8 次
     * 6.重复步骤 2 到 5 进行数据帧下一个字节的处理 直到将数据帧所有字节按上述步骤计算
     * 7.根据需要将寄存器的高、低字节进行交换 得到最终 CRC码
     *
     */

    /**
     * 初始值 CRC-16 寄存器
     */
    private static final int INITIAL_VALUE = 0xFFFF;
    private static final boolean IS_OUT_PUT_OVER_TURN = true;

    /**
     * 原始数据 + CRC码
     *
     * @param hexes 16 进制字符串
     * @return
     */
    public static byte[] getData(String... hexes) {
        byte[] data = new byte[hexes.length];
        int i = 0;
        for (String hex:hexes){
            //先转为数字在转为 byte
            data[i++] = (byte) Integer.parseInt(hex, 16);
        }
        return merge(data);
    }

    /**
     * 原始数据 + CRC码
     *
     * @param data
     * @return
     */
    public static byte[] merge(byte[] data) {
        byte[] crc = getCrc16(data);
        int dLen = data.length;
        int cLen = crc.length;
        byte[] result = new byte[dLen + cLen];
        System.arraycopy(data,0,result,0,dLen);
        System.arraycopy(crc,0,result,dLen,cLen);
        return result;
    }

    /**
     * 基于 CRC16 Modbus 计算校验码
     * CRC 16 Modbus 默认多项式为 x16+x15+x2+1 => 8005 反转即 A001
     *
     * @param data
     * @return
     */
    private static byte[] getCrc16(byte[] data) {
        int len = data.length;
        int crc = INITIAL_VALUE;
        int i, j;
        for (i = 0; i < len; i++) {
            // 把第一个 8 位二进制数据 与 16 位的 CRC寄存器的低 8 位相异或, 把结果放于 CRC寄存器
            crc = ((crc & 0xFF00) | (crc & 0x00FF) ^ (data[i] & 0xFF));
            for (j = 0; j < 8; j++) {
                // 把 CRC 寄存器的内容右移一位(朝低位)用 0 填补最高位, 并检查右移后的移出位
                if ((crc & 0x0001) > 0) {
                    // 如果移出位为 1, CRC寄存器与多项式A001进行异或
                    crc = crc >> 1;
                    crc = crc ^ 0xA001;
                } else {
                    // 如果移出位为 0,再次右移一位
                    crc = crc >> 1;
                }
            }
        }
        return intToBytes(crc);
    }

    /**
     * 将 int 转换成 byte 数组 低位在前 高位在后
     */
    private static byte[] intToBytes(int value)  {
        byte[] src = new byte[2];
        byte hig = (byte) ((value>>8) & 0xFF);
        byte low = (byte) (value & 0xFF);
        if (IS_OUT_PUT_OVER_TURN){
            src[0] = low;
            src[1] = hig;
        } else {
            src[0] = hig;
            src[1] = low;
        }
        return src;
    }

    /**
     * 将字节数组转换成十六进制字符串
     */
    public static String byteTo16String(byte[] data) {
        StringBuffer buffer = new StringBuffer();
        for (byte b : data) {
            byteToHex(buffer,b);
        }
        return buffer.toString().toUpperCase();
    }

    /**
     * 将字节转换成十六进制字符串
     *
     * int 转 byte 对照表
     * [128,255],0,[1,128)
     * [-128,-1],0,[1,128)
     */
    public static void byteToHex(StringBuffer buffer ,byte b) {
        if (b < 0) {
            buffer.append(Integer.toString(b + 256, 16));
        } else if (b == 0) {
            buffer.append("00 ");
        } else if (b > 0 && b <= 15) {
            buffer.append("0" + Integer.toString(b, 16));
        } else if (b > 15) {
            buffer.append(Integer.toString(b, 16));
        }
        buffer.append(" ");
    }
}

3.SerialUtil

package com.serial.demo.util;

import gnu.io.SerialPort;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;

/**
 * @author
 * @date 2023-07-03 21:52
 * @since 1.8
 */
public class SerialUtil {


    /**
     * 转为 HEX
     * @param str
     * @return
     */
    public static String toHex(String str){
        StringBuffer sbf = new StringBuffer();
        byte[] b = str.getBytes(StandardCharsets.UTF_8);
        for (int i = 0; i < b.length; i++) {
            String hex = Integer.toHexString(b[i] & 0xFF);
            if (hex.length() == 1) {
                hex = '0' + hex;
            }
            sbf.append(hex.toUpperCase() + "  ");
        }
        return sbf.toString().trim();
    }

    /**
     *
     * @param hex
     * @return
     */
    public static String toStr(String hex) {
        return new String(hexToByte(hex));
    }

    /**
     * 转 HEX 字节
     * @param hex
     * @return
     */
    public static byte[] hexToByte(String hex){
        hex = hex.toUpperCase().replace(" ","");
        ByteArrayOutputStream bao = new ByteArrayOutputStream(hex.length() / 2);
        // 将每2位16进制整数组装成一个字节
        for (int i = 0; i < hex.length(); i += 2) {
            bao.write((Common.HEX_STRING.indexOf(hex.charAt(i)) << 4 | Common.HEX_STRING.indexOf(hex.charAt(i + 1))));
        }
        return bao.toByteArray();
    }

    /**
     * 获取校验位配置
     * @param checkBit
     * @return
     */
    public static int getParity(String checkBit){
        if (Common.NONE.equals(checkBit)){
            return SerialPort.PARITY_NONE;
        } else if (Common.ODD.equals(checkBit)){
            return SerialPort.PARITY_ODD;
        } else if (Common.EVEN.equals(checkBit)){
            return SerialPort.PARITY_EVEN;
        } else {
            return SerialPort.PARITY_NONE;
        }
    }

    /**
     * 读取数据
     * @param in
     * @return
     */
    public static byte[] readFromPort(InputStream in) {
        byte[] bytes = {};
        try {
            // 缓冲区大小为一个字节
            byte[] readBuffer = new byte[1];
            int bytesNum = in.read(readBuffer);
            while (bytesNum > 0) {
                bytes = concat(bytes, readBuffer);
                bytesNum = in.read(readBuffer);
            }
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            try {
                if (in != null) {
                    in.close();
                    in = null;
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        return bytes;
    }

    /**
     * 字节转换
     * @param format
     * @param b
     * @return
     */
    public static String printHexString(String format, byte[] b) {
        String result = new String(b);
        if (Common.FORMAT_HEX.equals(format)){
            return SerialUtil.toHex(result);
        }
        return result;
    }

    /**
     * 合并数组
     *
     * @param firstArray  第一个数组
     * @param secondArray 第二个数组
     * @return 合并后的数组
     */
    public static byte[] concat(byte[] firstArray, byte[] secondArray) {
        if (firstArray == null || secondArray == null) {
            if (firstArray != null) {
                return firstArray;
            }
            if (secondArray != null) {
                return secondArray;
            }
            return null;
        }
        byte[] bytes = new byte[firstArray.length + secondArray.length];
        System.arraycopy(firstArray, 0, bytes, 0, firstArray.length);
        System.arraycopy(secondArray, 0, bytes, firstArray.length, secondArray.length);
        return bytes;
    }
}

4.WebSocket 配置

1.启动配置

package com.serial.demo.socket;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;

/**
 * @author
 * @date 2023-07-02 21:05
 * @since 1.8
 */
@Configuration
public class WebSocketConfig {

    /**
     * 开启 websocket 配置
     * @return
     */
    @Bean
    public ServerEndpointExporter serverEndpointExporter(){
        return new ServerEndpointExporter();
    }
}

2.监听配置

package com.serial.demo.socket;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import javax.websocket.OnClose;
import javax.websocket.OnMessage;
import javax.websocket.OnOpen;
import javax.websocket.Session;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * @author
 * @date 2023-07-02 21:07
 * @since 1.8
 */
@Slf4j
@Component
@ServerEndpoint("/websocket/{sid}")
public class SerialWebSocket {

    /**
     * 缓存通信实例
     */
    private static Map<String,SerialWebSocket> webSocketMap = new ConcurrentHashMap<>(16);

    /**
     * 会话
     */
    private Session session;

    /**
     * 标识
     */
    private String sid;

    /**
     * 建立连接
     * @param sid
     * @param session
     */
    @OnOpen
    public void onOpen(@PathParam("sid") String sid,Session session){
        this.session = session;
        this.sid = sid;
        webSocketMap.put(sid,this);
        //sendMessage(sid,"Hello:");
    }

    /**
     * 关闭连接
     * @param sid
     */
    @OnClose
    public void onClose(@PathParam("sid") String sid){
        try {
            SerialWebSocket socket = webSocketMap.remove(sid);
            if (socket != null){
                socket.session.close();
                socket = null;
            }
        } catch (IOException e) {
            log.error("Close {} exception:",sid,e);
        }
    }

    /**
     * 接收消息
     * @param message
     */
    @OnMessage
    public void onMessage(String message){
        log.info("sid {} msg {}",this.sid,message);
    }

    /**
     * 发送消息
     * @param message
     * @param sid
     */
    public static void sendMessage(String sid,String message){
        SerialWebSocket socket = webSocketMap.get(sid);
        if (socket != null){
            try {
                socket.session.getBasicRemote().sendText(message);
            } catch (IOException e) {
                log.error("Send {} message {} exception:",sid,message,e);
            }
        }
    }

    /**
     * 广播消息
     * @param message
     */
    public static void broadcast(String message){
        for (String sid:webSocketMap.keySet()){
            sendMessage(sid,message);
        }
    }

}

5.UI交互类

1.串口配置对象

package com.serial.demo.entity;

import lombok.Data;

/**
 * @author
 * @date 2023-07-02 22:58
 * @since 1.8
 */
@Data
public class SerialEntity {

    private String portId;
    private int bitRate;
    private int dataBit;
    private int stopBit;
    private String checkBit;
    private String format;

}

2.串口信息获取接口

package com.serial.demo.controller;

import com.serial.demo.config.SerialPortConfig;
import com.serial.demo.util.SerialUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

/**
 * @author
 * @date 2023-07-01 16:37
 * @since 1.8
 */
@CrossOrigin
@RestController
@RequestMapping("/serial")
public class SerialController {

    @Autowired
    SerialPortConfig serial;

    /**
     * 获取端口列表
     * @return
     */
    @GetMapping("/getSerialPortList")
    public List<String> getSerialPortList(){
        return serial.getSerialPortList();
    }

    /**
     * 字符串 转 HEX
     * @return
     */
    @GetMapping("/toHex")
    public String toHex(String str){
        return SerialUtil.toHex(str);
    }

    /**
     * HEX 转 字符串
     * @return
     */
    @GetMapping("/toStr")
    public String toStr(String hex){
        return SerialUtil.toStr(hex);
    }

}

3.RS232接口

package com.serial.demo.controller;

import com.serial.demo.config.Rs232Config;
import com.serial.demo.entity.SerialEntity;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

/**
 * @author
 * @date 2023-07-03 1:03
 * @since 1.8
 */
@CrossOrigin
@RestController
@RequestMapping("/serial/232")
public class Rs232Controller {

    @Autowired
    Rs232Config rs232Config;

    /**
     * 监听端口
     * @param serial
     */
    @PostMapping("/open")
    public boolean open(@RequestBody SerialEntity serial){
        return rs232Config.openPort(serial);
    }

    /**
     * 获取端口列表
     * @return
     */
    @GetMapping("/close/{portId}")
    public void close(@PathVariable("portId") String portId){
        rs232Config.closePort(portId);
    }

    /**
     * 获取端口列表
     * @return
     */
    @GetMapping("/send/{portId}/{format}/{msg}")
    public void close(@PathVariable("portId") String portId,@PathVariable("format") String format,@PathVariable("msg") String msg){
        rs232Config.sendData(portId,format,msg);
    }

}

4.RS485接口

package com.serial.demo.controller;

import com.serial.demo.config.Rs485Config;
import com.serial.demo.entity.SerialEntity;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

/**
 * @author
 * @date 2023-07-03 23:33
 * @since 1.8
 */
@CrossOrigin
@RestController
@RequestMapping("/serial/485")
public class Rs485Controller {

    @Autowired
    Rs485Config rs485Config;

    /**
     * 监听端口
     * @param serial
     */
    @PostMapping("/open")
    public boolean open(@RequestBody SerialEntity serial){
        return rs485Config.openPort(serial);
    }

    /**
     * 获取端口列表
     * @return
     */
    @GetMapping("/close/{portId}")
    public void close(@PathVariable("portId") String portId){
        rs485Config.closePort(portId);
    }

    /**
     * 获取端口列表
     * @return
     */
    @GetMapping("/send/{portId}/{format}/{msg}")
    public void close(@PathVariable("portId") String portId,@PathVariable("format") String format,@PathVariable("msg") String msg){
        rs485Config.sendData(portId,format,msg);
    }

}

6.串口配置类

1.串口配置

package com.serial.demo.config;

import gnu.io.CommPortIdentifier;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import java.util.Collections;
import java.util.Enumeration;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;

/**
 * @author
 * @date 2023-07-03 1:01
 * @since 1.8
 */
@Slf4j
@Component
public class SerialPortConfig {

    /**
     * 缓存端口信息
     */
    private static Map<String, CommPortIdentifier> serialMap;

    @PostConstruct
    private void init(){
        refreshCom();
    }

    /**
     * 刷新端口
     */
    public void refreshCom(){
        Enumeration<CommPortIdentifier> portList = CommPortIdentifier.getPortIdentifiers();
        CommPortIdentifier serial;
        Map<String,CommPortIdentifier> temp = new ConcurrentHashMap<>(16);
        while (portList.hasMoreElements()){
            serial = portList.nextElement();
            if (serial.getPortType() == CommPortIdentifier.PORT_SERIAL){
                temp.put(serial.getName(),serial);
            }
        }
        serialMap = Collections.unmodifiableMap(temp);
    }

    /**
     * 获取端口列表
     * @return
     */
    public List<String> getSerialPortList(){
        return serialMap.keySet().stream().sorted().collect(Collectors.toList());
    }

    /**
     * 获取串口
     * @return
     */
    public Map<String, CommPortIdentifier> getSerialMap(){
        return serialMap;
    }
}

2.RS232串口配置

package com.serial.demo.config;

import com.serial.demo.entity.SerialEntity;
import com.serial.demo.util.Common;
import com.serial.demo.util.SerialUtil;
import gnu.io.CommPortIdentifier;
import gnu.io.PortInUseException;
import gnu.io.SerialPort;
import gnu.io.UnsupportedCommOperationException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.TooManyListenersException;
import java.util.concurrent.ConcurrentHashMap;

/**
 * @author
 * @date 2023-07-01 16:22
 * @since 1.8
 */
@Slf4j
@Component
public class Rs232Config {

    private static final int DELAY_TIME = 1000;


    @Autowired
    SerialPortConfig config;

    /**
     * 缓存端口实例
     */
    private Map<String, SerialPort> serialPortMap = new ConcurrentHashMap<>(16);

    /**
     * 监听端口
     * @param serial
     */
    public boolean openPort(SerialEntity serial) {
        String portId = serial.getPortId();
        CommPortIdentifier commPortIdentifier = config.getSerialMap().get(portId);
        if (null != commPortIdentifier){
            SerialPort serialPort = null;
            int bitRate = 0,dataBit = 0,stopBit = 0,parity = 0;
            try {
                serialPort = (SerialPort) commPortIdentifier.open(portId,DELAY_TIME);
                // 设置监听器生效 当有数据时通知
                serialPort.notifyOnDataAvailable(true);
                // 比特率、数据位、停止位、奇偶校验位
                bitRate = serial.getBitRate();
                dataBit = serial.getDataBit();
                stopBit = serial.getStopBit();
                parity = SerialUtil.getParity(serial.getCheckBit());
                serialPort.setSerialPortParams(bitRate, dataBit, stopBit,parity);
            } catch (PortInUseException e) {
                log.error("Open CommPortIdentifier {} Exception:",serial.getPortId(),e );
                return false;
            } catch (UnsupportedCommOperationException e) {
                log.error("Set SerialPortParams BitRate {} DataBit {} StopBit {} Parity {} Exception:",bitRate,dataBit,stopBit,parity,e);
                return false;
            }

            // 设置当前串口的输入输出流
            InputStream input;
            OutputStream output;
            try {
                input = serialPort.getInputStream();
                output = serialPort.getOutputStream();
            } catch (IOException e) {
                log.error("Get serialPort data stream exception:",e);
                return false;
            }

            // 给当前串口添加一个监听器
            try {
                serialPort.addEventListener(new Serial232Listener(input,output,serial.getFormat()));
            } catch (TooManyListenersException e) {
                log.error("Get serialPort data stream exception:",e);
                return false;
            }
            serialPortMap.put(portId,serialPort);
            return true;
        }
        return false;
    }

    /**
     * 关闭端口
     * @param portId
     */
    public void closePort(String portId){
        SerialPort serialPort = serialPortMap.remove(portId);
        if (null != serialPort){
            serialPort.close();
        }
    }

    /**
     * 发送数据
     * @param portId
     * @param format
     * @param message
     */
    public void sendData(String portId,String format,String message){
        SerialPort serialPort = serialPortMap.get(portId);
        if (null == serialPort){
            return;
        }
        OutputStream output = null;
        try {
            byte[] bytes;
            if (Common.FORMAT_HEX.equals(format)){
                bytes = SerialUtil.hexToByte(message);
            } else {
                bytes = message.getBytes(StandardCharsets.UTF_8);
            }
            output = serialPort.getOutputStream();
            output.write(bytes);
            output.flush();
        } catch (IOException e) {
            throw new RuntimeException(e);
        } finally {
            if (null != output){
                try {
                    output.close();
                } catch (IOException e) {
                    throw new RuntimeException(e);
                }
            }
        }
    }

}

3.RS232串口监听

package com.serial.demo.config;

import com.serial.demo.socket.SerialWebSocket;
import com.serial.demo.util.Crc16Modbus;
import com.serial.demo.util.SerialUtil;
import gnu.io.SerialPortEvent;
import gnu.io.SerialPortEventListener;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;

/**
 * @author
 * @date 2023-07-01 17:06
 * @since 1.8
 */
public class Serial232Listener implements SerialPortEventListener {

    InputStream inputStream;
    OutputStream outputStream;
    String format;

    public Serial232Listener(InputStream input, OutputStream output, String format){
        inputStream = input;
        outputStream = output;
        this.format = format;
    }

    @Override
    public void serialEvent(SerialPortEvent event) {
        switch (event.getEventType()) {
            case SerialPortEvent.BI:
            case SerialPortEvent.OE:
            case SerialPortEvent.FE:
            case SerialPortEvent.PE:
            case SerialPortEvent.CD:
            case SerialPortEvent.CTS:
            case SerialPortEvent.DSR:
            case SerialPortEvent.RI:
            case SerialPortEvent.OUTPUT_BUFFER_EMPTY:
                break;
            case SerialPortEvent.DATA_AVAILABLE:
                // 当有可用数据时读取数据
                byte[] readBuffer = null;
                int availableBytes = 0;
                try {
                    availableBytes = inputStream.available();
                    while (availableBytes > 0) {
                        readBuffer = SerialUtil.readFromPort(inputStream);
                        String needData = Crc16Modbus.byteTo16String(readBuffer);
                        SerialWebSocket.broadcast(needData);
                        availableBytes = inputStream.available();
                    }
                } catch (IOException e) {
                }
            default:
                break;
        }
    }
}

4.RS485串口配置

package com.serial.demo.config;

import com.serial.demo.entity.SerialEntity;
import com.serial.demo.util.Common;
import com.serial.demo.util.Crc16Modbus;
import com.serial.demo.util.SerialUtil;
import gnu.io.CommPortIdentifier;
import gnu.io.PortInUseException;
import gnu.io.SerialPort;
import gnu.io.UnsupportedCommOperationException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.TooManyListenersException;
import java.util.concurrent.ConcurrentHashMap;

/**
 * @author
 * @date 2023-07-03 1:00
 * @since 1.8
 */
@Slf4j
@Component
public class Rs485Config {

    private static final int DELAY_TIME = 1000;


    @Autowired
    SerialPortConfig config;

    /**
     * 缓存端口实例
     */
    private Map<String, SerialPort> serialPortMap = new ConcurrentHashMap<>(16);

    /**
     * 监听端口
     * @param serial
     */
    public boolean openPort(SerialEntity serial) {
        String portId = serial.getPortId();
        CommPortIdentifier commPortIdentifier = config.getSerialMap().get(portId);
        if (null != commPortIdentifier){
            SerialPort serialPort;
            int bitRate = 0,dataBit = 0,stopBit = 0,parity = 0;
            try {
                serialPort = (SerialPort) commPortIdentifier.open(portId,DELAY_TIME);
                // 设置监听器生效 当有数据时通知
                serialPort.notifyOnDataAvailable(true);
                serialPort.setDTR(true);
                serialPort.setRTS(true);
                // 比特率、数据位、停止位、奇偶校验位
                bitRate = serial.getBitRate();
                dataBit = serial.getDataBit();
                stopBit = serial.getStopBit();
                parity = SerialUtil.getParity(serial.getCheckBit());
                serialPort.setSerialPortParams(bitRate, dataBit, stopBit,parity);
            } catch (PortInUseException e) {
                log.error("Open CommPortIdentifier {} Exception:",serial.getPortId(),e );
                return false;
            } catch (UnsupportedCommOperationException e) {
                log.error("Set SerialPortParams BitRate {} DataBit {} StopBit {} Parity {} Exception:",bitRate,dataBit,stopBit,parity,e);
                return false;
            }

            // 设置当前串口的输入输出流
            InputStream input;
            OutputStream output;
            try {
                input = serialPort.getInputStream();
                output = serialPort.getOutputStream();
            } catch (IOException e) {
                log.error("Get serialPort data stream exception:",e);
                return false;
            }

            // 给当前串口添加一个监听器
            try {
                serialPort.addEventListener(new Serial485Listener(input,output,serial.getFormat()));
            } catch (TooManyListenersException e) {
                log.error("Get serialPort data stream exception:",e);
                return false;
            }
            serialPortMap.put(portId,serialPort);
            return true;
        }
        return false;
    }

    /**
     * 关闭端口
     * @param portId
     */
    public void closePort(String portId){
        SerialPort serialPort = serialPortMap.remove(portId);
        if (null != serialPort){
            serialPort.close();
        }
    }

    /**
     * 发送数据
     * @param portId
     * @param format
     * @param message
     */
    public void sendData(String portId,String format,String message){
        SerialPort serialPort = serialPortMap.get(portId);
        if (null == serialPort){
            return;
        }
        OutputStream output = null;
        try {
            byte[] bytes = new byte[0];
            if (Common.FORMAT_HEX.equals(format)){
                bytes = SerialUtil.hexToByte(message);
                bytes = Crc16Modbus.merge(bytes);
            }

            output = serialPort.getOutputStream();
            output.write(bytes);
            output.flush();
        } catch (IOException e) {
            throw new RuntimeException(e);
        } finally {
            if (null != output){
                try {
                    output.close();
                } catch (IOException e) {
                    throw new RuntimeException(e);
                }
            }
        }
    }
}

5.RS485串口监听

package com.serial.demo.config;

import com.serial.demo.socket.SerialWebSocket;
import com.serial.demo.util.Crc16Modbus;
import com.serial.demo.util.SerialUtil;
import gnu.io.SerialPortEvent;
import gnu.io.SerialPortEventListener;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;

/**
 * @author
 * @date 2023-07-03 23:21
 * @since 1.8
 */
public class Serial485Listener implements SerialPortEventListener {

    InputStream inputStream;
    OutputStream outputStream;
    String format;

    public Serial485Listener(InputStream input, OutputStream output, String format){
        inputStream = input;
        outputStream = output;
        this.format = format;
    }

    @Override
    public void serialEvent(SerialPortEvent event) {
        switch (event.getEventType()) {
            case SerialPortEvent.BI:
            case SerialPortEvent.OE:
            case SerialPortEvent.FE:
            case SerialPortEvent.PE:
            case SerialPortEvent.CD:
            case SerialPortEvent.CTS:
            case SerialPortEvent.DSR:
            case SerialPortEvent.RI:
            case SerialPortEvent.OUTPUT_BUFFER_EMPTY:
                break;
            case SerialPortEvent.DATA_AVAILABLE:
                // 当有可用数据时读取数据
                byte[] readBuffer = null;
                int availableBytes = 0;
                try {
                    availableBytes = inputStream.available();
                    while (availableBytes > 0) {
                        readBuffer = SerialUtil.readFromPort(inputStream);
                        String needData = printHexString(readBuffer);
                        SerialWebSocket.broadcast(needData);
                        availableBytes = inputStream.available();
                    }
                } catch (IOException e) {
                }
            default:
                break;
        }
    }

    /**
     * 转为 16 进制字符串
     * @param b
     * @return
     */
    public static String printHexString(byte[] b) {
        return Crc16Modbus.byteTo16String(b);
    }

}

三.UI代码

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="utf-8" />
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>Serial Communication</title>
        <meta name="robots" content="all" />
        <meta name="keywords" content="Serial Communication" />
        <meta name="description" content="Serial Communication" />
        <style>
            .btn-group{
                display: inline-block;
            }
            .left {
                width: 300px;
                height: 500px;
                float: left;
            }
            .right {
                width: calc(100% - 330px);
                height: 500px;
                margin-left: 300px;
            }

            .configSelect{
                width: 100%;
            }

            .bk{
                border-color: #D5DBDB;
                background-color: #1E1E1E;
                margin: 1px;
            }

            .bkw{
                border-color: #D5DBDB;
                background-color: #1E1E1E;
                margin: 1px;
                border-left: none;
                border-right: none;
                border-bottom: none;
                border-top: none;
            }

            select{
                background-color: #3C3C3C;
                color: white;
            }

            button{
                background-color: #3C3C3C;
                color: white;
                cursor: pointer;
            }


        </style>
    </head>
    <body style="background-color: #1E1E1E;color: white;">
        <div>
            <div class="left">
                <fieldset class="bk">
                    <legend>串口配置</legend>
                    <div style="background: #1E1E1E;width: 260px;">
                        <table>
                            <tr><th style="width:100px">端口</th><td style="width:160px"><select class="configSelect" id="serialPortList"></select></td></tr>
                            <tr><th>波特率</th><td><select class="configSelect" id="bitRate"></select></td></tr>
                            <tr><th>数据位</th><td><select class="configSelect" id="dataBit"></select></td></tr>
                            <tr><th>停止位</th><td><select class="configSelect" id="stopBit"></select></td></tr>
                            <tr><th>校验位</th><td><select class="configSelect" id="checkBit"></select></td></tr>
                            <tr><th>操作</th><td><button type="button" class="btn btn-default" style="width:100%" id="switchSerialPort" >打开串口</button></td></tr>
                        </table>

                    </div>
                </fieldset>
                <fieldset class="bk">
                    <legend>RS485</legend>
                    <div>
                        <table>
                            <tr><th style="width:100px">RS485</th><td style="width:160px"><input type="checkbox" id="isRS485"/></td></tr>
                            <!-- <tr><th>DTR</th><td><input type="checkbox" id="isDTR"/></td></tr>
                            <tr><th>RTS</th><td><input type="checkbox" id="isRTS"/></td></tr> -->
                        </table>
                    </div>
                </fieldset>
                <fieldset class="bk">
                    <legend>接收区设置</legend>
                    <div>
                        <table>
                            <tr><th style="width:100px">数据格式</th><td style="width:160px"><select class="configSelect" id="receiveDataType"></select></td></tr>
                            <tr><th>停止显示</th><td><input type="checkbox" id="stopShow"/></td></tr>
                            <tr><th></th><td><button type="button" class="btn btn-default" style="width:100%" id="clearReceiveData" >清空接收区</button></td></tr>
                            <tr><th></th><td><button type="button" class="btn btn-default" style="width:100%" id="saveToFile" >保存到文件</button></td></tr>
                        </table>
                    </div>
                </fieldset>
                
                

            </div>
            <div class="right">
                <fieldset class="bkw">
                    <legend>WebSocket</legend>
                        <input type='text' value='ws://localhost:8080/websocket/test-0' class="form-control" style='width:390px;display:inline' id='wsaddr' />
                        <div class="btn-group" >
                            <button type="button" class="btn btn-default" onclick='addsocket();'>连接</button>
                            <button type="button" class="btn btn-default" onclick='closesocket();'>断开</button>
                            <button type="button" class="btn btn-default" onclick='$("#wsaddr").val("")' style="display:none">清空</button>
                        </div>
                        <div class="row">
                            <div id="output" style="border:1px solid #ccc;height:390px;overflow: auto;margin: 20px 0;background: #4B4B4B;"></div>
                            <div style="display:none">
                                <input type="text" id='message' class="form-control" style='width:810px' placeholder="待发信息" onkeydown="en(event);">
                                <span class="input-group-btn">
                                    <button class="btn btn-default" type="button" onclick="doSend();">发送</button>
                                </span>
                            </div>
                        </div>
                </fieldset>    
                
                
            </div>
        </div>
        <div>
            <div class="left" style="height: 160px;">
                <fieldset class="bk">
                    <legend>发送区设置</legend>
                        <table>
                        <tr><th style="width:100px">自动发送</th><td style="width:160px"><input id="autoSendTimer" value="1000"/></td></tr>
                        <tr><th>数据格式</th><td><select class="configSelect" id="dataType"></select></td></tr>
                        <tr><th>类型</th><td><select class="configSelect" id="sendType"></select></td></tr>
                        <tr><th>发送</th><td><button type="button" class="btn btn-default" style="width:100%" id="sendData" >发送数据</button></td></tr>
                    </table>
                </fieldset>
            </div>
            
            <div class="right" style="height: 160px;">
                <fieldset class="bkw">
                    <legend></legend>
                    <textarea id="sendMessages" style="width:100%;height:133px;margin-top: 5px;background-color: #4B4B4B;color: white;"></textarea>
                </fieldset>
                
            </div>
        </div>

        <div>
            <div class="left" style="height: 90px;">
                <fieldset class="bk">
                    <legend>类型转换</legend>
                        <table>
                        <tr><th style="width:90px"></th><td style="width:160px"><button type="button" class="btn btn-default" style="width:100%" id="toHex" >字符串转HEX</button></td></tr>
                        <tr><th></th><td><button type="button" class="btn btn-default" style="width:100%" id="toStr" >HEX转字符串</button></td></tr>
                        </tr>
                    </table>
                </fieldset>
            </div>
            
            <div class="right" style="height: 90px;">
                <table style="width:100%">
                    <tr style="width:100%">
                        <th style="width:50%">
                            <fieldset class="bkw">
                                <legend>STR</legend>
                                <textarea id="strShow" style="width:100%;height:54px;margin-top: 5px;background-color: #4B4B4B;color: white;"></textarea>
                            </fieldset>
                        </th>
                        <th style="width:50%">
                            <fieldset class="bkw">
                                <legend>HEX</legend>
                                <textarea id="hexShow" style="width:100%;height:54px;margin-top: 5px;background-color: #4B4B4B;color: white;"></textarea>
                            </fieldset>
                        </th>
                    </tr>
                </table>
            </div>
        </div>
    </body>     
        
        <script src="https://code.jquery.com/jquery-3.1.1.min.js"></script>
        <script language="javascript" type="text/javascript">

            function formatDate(now) {
                var year = now.getFullYear();
                var month = now.getMonth() + 1;
                var date = now.getDate();
                var hour = now.getHours();
                var minute = now.getMinutes();
                var second = now.getSeconds();
                return year + "-" + (month = month < 10 ? ("0" + month) : month) + "-" + (date = date < 10 ? ("0" + date) : date) +
                    " " + (hour = hour < 10 ? ("0" + hour) : hour) + ":" + (minute = minute < 10 ? ("0" + minute) : minute) + ":" + (
                        second = second < 10 ? ("0" + second) : second);
            }
            var output;
            var websocket;
 
            function addsocket() {
                $("#output").text("");
                var wsaddr = $("#wsaddr").val();
                if (wsaddr == '') {
                    alert("set websocket address!");
                    return false;
                }
                StartWebSocket(wsaddr);
            }
 
            function closesocket() {
                websocket.close();
            }
 
            function StartWebSocket(wsUri) {
                websocket = new WebSocket(wsUri);
                websocket.onopen = function(evt) {
                    onOpen(evt)
                };
                websocket.onclose = function(evt) {
                    onClose(evt)
                };
                websocket.onmessage = function(evt) {
                    onMessage(evt)
                };
                websocket.onerror = function(evt) {
                    onError(evt)
                };
            }
 
            function onOpen(evt) {
                // writeToScreen("<span style='color:red'>连接成功,现在你可以发送信息啦!!!</span>");
            }
 
            function onClose(evt) {
                // writeToScreen("<span style='color:red'>websocket连接已断开!!!</span>");
                websocket.close();
            }
 
            function onMessage(evt) {
                var stopShow = $("#stopShow").prop('checked');
                if (!stopShow) {
                    writeToScreen('<span style="color:blue">' + formatDate(new Date()) + ' : </span><span class="bubble">' + evt.data + '</span>');
                }
            }
 
            function onError(evt) {
                writeToScreen('<span style="color: red;">error:</span> ' + evt.data);
            }
 
            function doSend() {
                var message = $("#message").val();
                if (message == '') {
                    alert("Please input message");
                    $("#message").focus();
                    return false;
                }
                if (typeof websocket === "undefined") {
                    alert("websocket is not connected");
                    return false;
                }
                if (websocket.readyState == 3) {
                    alert("websocket is closed,please reconnected");
                    return false;
                }
                console.log(websocket);
                $("#message").val('');
                writeToScreen('<span style="color:green">你发送的信息&nbsp;' + formatDate(new Date()) + '</span><br/>' + message);
                websocket.send(message);
            }
 
            function writeToScreen(message) {
                var div = "<div class='newmessage'>" + message + "</div>";
                var d = $("#output");
                var d = d[0];
                var doScroll = d.scrollTop == d.scrollHeight - d.clientHeight;
                $("#output").append(div);
                if (doScroll) {
                    d.scrollTop = d.scrollHeight - d.clientHeight;
                }
            }
 
 
            function en(event) {
                var evt = evt ? evt : (window.event ? window.event : null);
                if (evt.keyCode == 13) {
                    doSend()
                }
            }


            var http = "http://localhost:8080/serial";
            var httpSerial = "http://localhost:8080/serial/232";


            function getSerialPortList(){
                $("#serialPortList").html('');
                $.ajax({url:http + "/getSerialPortList",success:function(result){
                    for (var i in result) {
                        $("#serialPortList").append("<option value='" + result[i] + "''>" + result[i] + "</option>");
                    }
                }});
            }

            function getBitRate() {
                var list = [2400,4800,9600,19200,38400,57600,115200,128000,230400,256000,460800]
                for (var i in list) {
                    $("#bitRate").append("<option value='" + list[i] + "''>" + list[i] + "</option>");
                }

                var list = [5,6,7,8]
                for (var i in list) {
                    $("#dataBit").append("<option value='" + list[i] + "''>" + list[i] + "</option>");
                }

                var list = [1,2]
                for (var i in list) {
                    $("#stopBit").append("<option value='" + list[i] + "''>" + list[i] + "</option>");
                }

                var list = ['无','奇','偶']
                for (var i in list) {
                    $("#checkBit").append("<option value='" + list[i] + "''>" + list[i] + "</option>");
                }

                var list = ['ASCII','HEX']
                for (var i in list) {
                    $("#dataType").append("<option value='" + list[i] + "''>" + list[i] + "</option>");
                    $("#receiveDataType").append("<option value='" + list[i] + "''>" + list[i] + "</option>");
                }

                var list = ['发送新行','自动发送']
                for (var i in list) {
                    $("#sendType").append("<option value='" + list[i] + "''>" + list[i] + "</option>");
                }

            }

            function updateConfig(type){
                if (type == 1) {
                    $("#serialPortList").attr("disabled","disabled")
                    $("#bitRate").attr("disabled","disabled")
                    $("#dataBit").attr("disabled","disabled")
                    $("#stopBit").attr("disabled","disabled")
                    $("#checkBit").attr("disabled","disabled")
                    $("#receiveDataType").attr("disabled","disabled")
                    $("#isRS485").attr("disabled","disabled")
                } else {
                    $("#serialPortList").removeAttr("disabled")
                    $("#bitRate").removeAttr("disabled")
                    $("#dataBit").removeAttr("disabled")
                    $("#stopBit").removeAttr("disabled")
                    $("#checkBit").removeAttr("disabled")
                    var isRS485 = $("#isRS485").prop('checked');
                    if (!isRS485) {
                        $("#receiveDataType").removeAttr("disabled")
                    }
                    $("#isRS485").removeAttr("disabled")
                }
            }

            function init() {
                addsocket();
                getSerialPortList();
                getBitRate();
                $("#bitRate").val(9600)
                $("#dataBit").val(8)
            }

            init();

            $("#switchSerialPort").click(function(){

                var status = $("#switchSerialPort").html();
                if (status == '关闭串口') {
                    $.ajax({
                        url:httpSerial + "/close/" +  $("#serialPortList").val(),
                        success:function(result){
                            $("#switchSerialPort").html('打开串口')
                            $("#switchSerialPort").css("background-color","");
                            updateConfig(0)
                        },
                        error:function(result){

                        }
                    });
                } else {
                    var portId = $("#serialPortList").val();
                    var bitRate = $("#bitRate").val();
                    var dataBit = $("#dataBit").val();
                    var stopBit = $("#stopBit").val();
                    var checkBit = $("#checkBit").val();
                    var format =  $("#receiveDataType").val();
                    var serialData = {"portId": portId, "bitRate": bitRate, "dataBit": dataBit, "stopBit": stopBit, "checkBit": checkBit,"format":format};
                    // console.log(serialData)
                    $.ajax({
                        url:httpSerial + "/open" ,
                        type: "post",
                        contentType : "application/json",
                        dataType: "json",
                        data: JSON.stringify(serialData),
                        success:function(result){
                            if (result) {
                                $("#switchSerialPort").html('关闭串口')
                                $("#switchSerialPort").css("background-color","#02A1DD");
                                updateConfig(1)
                            }
                        },
                        error:function(result){

                        }
                    });

                }
            });

            $("#sendData").click(function(){
                var portId = $("#serialPortList").val();
                var data = $("#sendMessages").val();
                var dataType = $("#dataType").val();

                if (data == '') {
                    alert('Please input message .')
                    $("#sendMessages").focus();
                    return;
                }

                $.ajax({
                    url:httpSerial + "/send/" + portId + "/" + dataType + "/" + data,
                    success:function(result){
                        var sendType = $("#sendType").val();
                        if (sendType == '发送新行') {
                            $("#sendMessages").val('');
                        }
                    },
                    error:function(result){

                    }
                });
            });    

            var timer 
            $("#sendType").change(()=>{
                var data = $("#sendMessages").val();
                if (data == '') {
                    clearInterval(timer)
                    $("#sendType").val("发送新行");
                    $("#sendType").attr("disabled","disabled")
                }

                var sendType = $("#sendType").val();
                if (sendType == '自动发送') {
                    $("#sendData").attr("disabled","disabled")
                    $("#sendData").css("background-color","#02A1DD");
                    var interval = $("#autoSendTimer").val()
                    timer = setInterval(function(){
                        $("#sendData").trigger("click")
                    },interval);
                } else {
                    clearInterval(timer)
                    $("#sendData").removeAttr("disabled")
                    $("#sendData").css("background-color","");
                }
            })

            $("#sendType").attr("disabled","disabled")
            $("#sendMessages").keyup(()=>{
                var data = $("#sendMessages").val();
                if (data == '') {
                    clearInterval(timer)
                    $("#sendType").val("发送新行");
                    $("#sendType").attr("disabled","disabled")
                } else {
                    $("#sendType").removeAttr("disabled")
                }
            })

            $("#clearReceiveData").click(function(){
                $("#output").text("");
            });    

            $("#saveToFile").click(function(){
                let a = document.createElement('a')
                let url = window.URL.createObjectURL(
                  new Blob([$("#output").text()], {
                    type: ''
                  })
                )
                a.href = url
                a.download = 'ReveiveData' + Date.parse(new Date()) + '.log'
                a.click()
                window.URL.revokeObjectURL(url)
            }); 


            $("#toHex").click(function(){
                var str = $("#strShow").val();
                $.ajax({
                    url:http + "/toHex?str=" + str,
                    success:function(result){
                        $("#hexShow").val(result)
                    },
                    error:function(result){

                    }
                });
            })

            $("#toStr").click(function(){
                var hex = $("#hexShow").val();
                $.ajax({
                    url:http + "/toStr?hex=" + hex,
                    success:function(result){
                        $("#strShow").val(result)
                    },
                    error:function(result){

                    }
                });
            })

            $("#isRS485").click(function(){
                var isRS485 = $("#isRS485").prop('checked');
                if (isRS485) {
                    $("#dataType").val("HEX");
                    $("#receiveDataType").val("HEX");
                    $("#dataType").attr("disabled","disabled")
                    $("#receiveDataType").attr("disabled","disabled")
                    httpSerial = "http://localhost:8080/serial/485";
                } else {
                    $("#dataType").removeAttr("disabled")
                    $("#receiveDataType").removeAttr("disabled")
                    httpSerial = "http://localhost:8080/serial/232";
                }
            })
 
        </script>
 
</html>

四.测试效果

1.串口通信

ASCII 收数

在这里插入图片描述

ASCII发数

在这里插入图片描述

切换为自动发送后即自动发送当前数据

在这里插入图片描述

Hex 收数

在这里插入图片描述

Hex 发数

在这里插入图片描述

2.CRC16通信

Hex 收数

在这里插入图片描述

Hex 发数

在这里插入图片描述

  • 33
    点赞
  • 125
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 7
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 7
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

猪悟道

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值