从0到1打造一款WebStyle串口调试工具

Tip:No Ego

Some programmers have a huge problem: their own ego. But there is no time for developing an ego. There is no time for being a rockstar.

Who is it who decides about your quality as programmer? You? No. The others? Probably. But can you really compare an Apple with a Banana? No. You are an individual. You cannot compare your whole self with another human being. You can only compare a few facettes.

A facet is nothing what you can be proud of. You are good at Java? Cool. The other guy is not as good as you, but better with bowling. Is Java more important than bowling? It depends on the situation. Probably you earn more money with Java, but the other guy might have more fun in life because of his bowling friends.

Can you really be proud because you are a geek? Programmers with ego don’t learn. Learn from everybody, from the experienced and from the noobs at the same time.

Kodo Sawaki once said: you are not important.

Think about it.

——The 10 rules of a Zen programmer

零、背景:为什么要造这个轮子

传统的桌面应用大多数是低代码例如 WinForm、WPF、QT 等基于现有的组件进行拖拽式开发,如果没有特别去优化改善界面,用户体验感是很差的,因此衍生出一种嵌入式浏览器方案 CEF,尝试使用现有的前端技术去解决桌面 UI 问题。

基于这个背景下,本文从学习研究的角度实现一个示例以探索 CEF 解决方案在工业领域的应用,现模拟一个工业调试设备的场景,例如从称重机中获取重量、发送亮灯信号、控制电路开关等。串口调试工具用于检验硬件设备是否能够正常运作,如下图所示:

  • Step1、界面上选择设备的串口参数
  • Step2、根据串口参数连接到设备
  • Step3、读取并解析设备返回的数据
  • Step4、将数据回显到界面上
  • Step5、根据界面的数据判断设备运行情况

一、技术栈

Vite + Vue3 + TS + WebSocket+ ElementUI(plus) + .NET Framework 4.7.2 + WPF + SQLITE3,开发环境为 Win10,VS2019,VS Code。 

二、后端设计与实现

开发环境(补充)

1、WS服务器类WebSocketServer

安装 fleck 库,这里使用的版本是 1.2.0,

using Fleck;
using System.Diagnostics;

namespace SerialDevTool.WS
{
    class MyWebSocketServer
    {
        /// <summary>
        /// 运行 WS 服务器
        /// </summary>
        public static void Run()
        {
            FleckLog.Level = LogLevel.Debug;
            var server = new WebSocketServer("ws://127.0.0.1:3000");
            server.Start(socket =>
            {
                // 建立连接
                socket.OnOpen = () =>
                {
                    Debug.WriteLine("客户端连接成功");
                };
                // 关闭连接
                socket.OnClose = () =>
                {
                    Debug.WriteLine("客户端已经关闭");
                };
                // 收到消息
                socket.OnMessage = message =>
                {
                    Debug.WriteLine(string.Format("收到客户端信息:{0}",message));
                    socket.Send(message);
                };
                // 发生错误
                socket.OnError = exception => {
                    Debug.WriteLine(string.Format("发生错误:{0}",exception.Message));
                };
            });
            Debug.WriteLine("WS服务器已启动");
        }
    }
}

这里我们创建了一个 WS 服务器,地址为 ws://127.0.0.1:3000 ,并且实现了 OnOpen、OnClose 、OnMessage、OnError 对应的方法,启动方式如下,

Task.Run(() =>
{
    MyWebSocketServer.Run();
});

使用 Postman 测试 WS,点击左上角 File–> New,选择 WebSocket,

可以看到,Postman 向服务器发送 hello world,服务器也向 Postman 返回 hello world,

2、串口通讯工具类SerialPortlUtil

using System;
using System.Diagnostics;
using System.IO.Ports;

namespace SerialDevTool.Utils
{
    /// <summary>
    /// 串口工具类
    /// </summary>
    public class SerialPortlUtil
    {
        /// <summary>
        /// 默认偏移
        /// </summary>
        private static readonly int OFFSET = 0;
        /// <summary>
        /// 默认数据位
        /// </summary>
        private static readonly int COUNT = 8;
        /// <summary>
        /// 默认超时时间,单位 ms
        /// </summary>
        private static readonly int DEFAULT_TIMEOUT = 500;
        /// <summary>
        /// 默认COM口
        /// </summary>
        private static readonly string DEFAULT_COM = "COM1";
        /// <summary>
        /// 默认波特率
        /// </summary>
        private static readonly int DEFAULT_BAUDRATE = 9600;
        /// <summary>
        /// 默认校验位
        /// </summary>        
        private static readonly Parity DEFAULT_PARITY = Parity.None;
        /// <summary>
        /// 默认数据位
        /// </summary>
        private static readonly int DEFAULT_DATABITS = 8;
        /// <summary>
        /// 默认停止位
        /// </summary>
        private static readonly StopBits DEFAULT_STOPBITS = StopBits.One;

        /// <summary>
        /// 获取默认串口实例
        /// </summary>
        public static SerialPort GetDefaultSerialPortInstance()
        {
            return GetSerialPortInstance(DEFAULT_COM);
        }
        /// <summary>
        /// 获取串口实例
        /// </summary>
        /// <param name="com"></param>
        /// <returns></returns>
        public static SerialPort GetSerialPortInstance(string com)
        {
            // COM1,9600,0,8,1
            if (com.Contains(","))
            {
                string[] comParams = com.Split(new string[] { "," }, StringSplitOptions.None);

                return new SerialPort(comParams[0], int.Parse(comParams[1]), GetParity(comParams[2]), int.Parse(comParams[3]), GetStopBits(comParams[4]))
                {
                    ReadTimeout = DEFAULT_TIMEOUT,
                    WriteTimeout = DEFAULT_TIMEOUT
                };
            }

            // COM1
            return new SerialPort(com, DEFAULT_BAUDRATE, DEFAULT_PARITY, DEFAULT_DATABITS, DEFAULT_STOPBITS)
            {
                ReadTimeout = DEFAULT_TIMEOUT,
                WriteTimeout = DEFAULT_TIMEOUT
            };
        }

        /// <summary>
        /// 解析停止位
        /// </summary>
        /// <param name="stopBits"></param>
        /// <returns></returns>
        public static StopBits GetStopBits(string stopBits)
        {
            switch (stopBits)
            {
                case "0":
                    {
                        return StopBits.None;
                    }
                case "1":
                    {
                        return StopBits.One;
                    }
                case "2":
                    {
                        return StopBits.Two;
                    }
                case "3":
                    {
                        return StopBits.OnePointFive;
                    }
                default:
                    return StopBits.One;
            }
        }

        /// <summary>
        /// 解析校验位
        /// </summary>
        /// <param name="parity"></param>
        /// <returns></returns>
        public static Parity GetParity(string parity)
        {
            switch (parity)
            {
                case "0":
                    {
                        return Parity.None;
                    }
                case "1":
                    {
                        return Parity.Odd;
                    }
                case "2":
                    {
                        return Parity.Even;
                    }
                case "3":
                    {
                        return Parity.Mark;
                    }
                case "4":
                    {
                        return Parity.Space;
                    }
                default:
                    return Parity.None;
            }
        }

        /// <summary>
        /// 写入 8 位字节数据
        /// </summary>
        /// <param name="serialPort"></param>
        /// <param name="buffer"></param>
        public static void Write(SerialPort serialPort, byte[] buffer)
        {
            try
            {
                if (!serialPort.IsOpen)
                {
                    serialPort.Open();
                }
                serialPort.Write(buffer, OFFSET, COUNT);
            }
            catch (Exception ex)
            {
                Debug.WriteLine(string.Format("Write Exception: {0}", ex.Message));
            }
        }
        /// <summary>
        /// 将指定的字符串和 System.IO.Ports.SerialPort.NewLine 值写入输出缓冲区。
        /// </summary>
        /// <param name="serialPort"></param>
        /// <param name="text"></param>
        public static void WriteLine(SerialPort serialPort, string text)
        {
            try
            {
                if (!serialPort.IsOpen)
                {
                    serialPort.Open();
                }
                serialPort.WriteLine(text);
            }
            catch (Exception ex)
            {
                Debug.WriteLine(string.Format("WriteLine Exception: {0}", ex.Message));
            }
        }

        /// <summary>
        /// 读 8 位字节数据
        /// </summary>
        /// <param name="serialPort"></param>
        /// <param name="buffer"></param>
        public static int Read(SerialPort serialPort, byte[] buffer)
        {
            try
            {
                if (!serialPort.IsOpen)
                {
                    serialPort.Open();
                }
                return serialPort.Read(buffer, OFFSET, COUNT);
            }
            catch (Exception ex)
            {
                Debug.WriteLine(string.Format("Read Exception: {0}", ex.Message));
            }
            return 0;
        }

        /// <summary>
        ///  一直读取到输入缓冲区中的 System.IO.Ports.SerialPort.NewLine 值。
        /// </summary>
        /// <param name="serialPort"></param>
        /// <returns></returns>
        public static string ReadLine(SerialPort serialPort)
        {
            string line = "";
            try
            {
                if (!serialPort.IsOpen)
                {
                    serialPort.Open();
                }
                line = serialPort.ReadLine();
            }
            catch (Exception ex)
            {
                Debug.WriteLine(string.Format("ReadLine Exception: {0}", ex.Message));
            }
            return line;
        }
    }
}

使用虚拟串口测试,

        /// <summary>
        /// 测试串口通讯
        /// </summary>
        private void TestSerialPort()
        {
            Task.Run(() =>
            {
                SerialPort readCom = SerialPortlUtil.GetSerialPortInstance("COM6");
                int length = 0;
                while (true)
                {
                    byte[] buffer = new byte[8];
                    length = SerialPortlUtil.Read(readCom, buffer);
                    if (length > 0)
                    {
                        Debug.Write("receive: ");
                        for (int i = 0; i < length; i++)
                        {
                            Debug.Write(string.Format("{0} ", buffer[i]));
                        }
                        Debug.Write("\n");
                        Thread.Sleep(1000);
                    }
                }
            });
            Task.Run(() =>
            {
                SerialPort writeCom = SerialPortlUtil.GetSerialPortInstance("COM5");
                while (true)
                {
                    SerialPortlUtil.Write(writeCom, new byte[] { 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08 });
                    Thread.Sleep(500);
                }
            });
        }

这里的虚拟串口 COM5 每 500ms 向缓存区写入数据,COM6 每 1000ms 从缓存区中读取数据,SerialPort 读写数据类型均支持 Byte、Char、String,

3、将串口通讯绑定到WS方法

using Fleck;
using SerialDevTool.Utils;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO.Ports;
using System.Threading;
using System.Threading.Tasks;

namespace SerialDevTool.WS
{
    class MyWebSocketServer
    {
        /// <summary>
        /// 写标志
        /// </summary>
        private const string WRITE_FLAG = "##WRITE##";
        private readonly string[] WRITE_FLAG_SEPARATOR = new string[] { WRITE_FLAG };

        /// <summary>
        /// 打开串口标志
        /// </summary>
        private const string OPEN_FLAG = "##OPEN##";
        private readonly string[] OPEN_FLAG_SEPARATOR = new string[] { OPEN_FLAG };
        /// <summary>
        /// 关闭串口标志
        /// </summary>
        private const string CLOSE_FLAG = "##CLOSE##";
        private readonly string[] CLOSE_FLAG_SEPARATOR = new string[] { CLOSE_FLAG };

        /// <summary>
        /// 当前连接的 socket
        /// </summary>
        private Dictionary<string,IWebSocketConnection> _webSocketDic;

        /// <summary>
        /// 当前连接的串口
        /// </summary>
        private Dictionary<string, SerialPort> _serialPortDic;

        public MyWebSocketServer(){
            this._webSocketDic = new Dictionary<string, IWebSocketConnection>();
            this._serialPortDic = new Dictionary<string, SerialPort>();
        }

        /// <summary>
        /// 运行 WS 服务器
        /// </summary>
        public void Run()
        {
            FleckLog.Level = LogLevel.Debug;
            var server = new WebSocketServer("ws://127.0.0.1:3000");
            server.Start(socket =>
            {
                // 建立连接
                socket.OnOpen = () =>
                {
                    Debug.WriteLine("客户端连接成功");
                    // 获取请求路径参数
                    string pathParams = GetPathParams(socket.ConnectionInfo.Path);
                    Debug.WriteLine($"WebSocket opened with path params: {pathParams}");
                };
                // 关闭连接
                socket.OnClose = () =>
                {
                    Debug.WriteLine("客户端已经关闭");
                    // 移除缓存 socket
                    this._webSocketDic.Remove(GetClientKey(socket.ConnectionInfo.ClientIpAddress, socket.ConnectionInfo.ClientPort));
                };
                // 收到消息
                socket.OnMessage = message =>
                {
                    Debug.WriteLine(string.Format("收到客户端信息: {0}", message));

                    // 鉴权
                    if (message.StartsWith("Authorization: Bearer"))
                    {
                        // 进行身份验证逻辑,检查 token 令牌的有效性
                        string token = message.Split(' ')[2];
                        if (ValidateToken(token))
                        {
                            // 身份验证通过,处理业务逻辑
                            // 新增缓存 socket
                            this._webSocketDic.Add(GetClientKey(socket.ConnectionInfo.ClientIpAddress, socket.ConnectionInfo.ClientPort), socket);
                            // 返回信息
                            socket.Send("Authorization: PASS");
                        }
                        else
                        {
                            // 身份验证失败,关闭连接
                            socket.Close();
                        }
                        return;
                    }


                    // 处理信息
                    HandleMessage(GetClientKey(socket.ConnectionInfo.ClientIpAddress, socket.ConnectionInfo.ClientPort), message);


                };
                // 发生错误
                socket.OnError = exception =>
                {
                    Debug.WriteLine(string.Format("发生错误: {0}", exception.Message));
                };
            });

            Debug.WriteLine("WS服务器已启动");
        }
        /// <summary>
        /// 获取客户端 ip + port
        /// </summary>
        /// <param name="socket"></param>
        /// <returns></returns>
        private string GetClientKey(string ip,int port )
        {
            return string.Format("{0}:{1}", ip, port);
        }
        /// <summary>
        /// 获取客户端串口Key
        /// </summary>
        /// <param name="clientKey"></param>
        /// <param name="com"></param>
        /// <returns></returns>
        private string GetClientSerialKey(string clientKey, string com)
        {
            return string.Format("{0}:{1}", clientKey, com);
        }
        /// <summary>
        /// 检验 token 是否有效
        /// </summary>
        /// <param name="token"></param>
        /// <returns></returns>
        private bool ValidateToken(string token)
        {
            return true;
        }
        /// <summary>
        /// 解析参数
        /// </summary>
        /// <param name="path"></param>
        /// <returns></returns>
        private string GetPathParams(string path)
        {
            // 解析路径参数
            int startIndex = path.LastIndexOf('/') + 1;
            return path.Substring(startIndex);
        }

        /// <summary>
        /// 发送信息到客户端
        /// </summary>
        /// <param name="clientKey"></param>
        /// <param name="message"></param>
        public void SendMessage(string clientKey,string message)
        {
            if (this._webSocketDic != null && this._webSocketDic.ContainsKey(clientKey))
            {
                Debug.WriteLine(string.Format("发送给客户端{0}: {1}", clientKey, message));
                this._webSocketDic[clientKey].Send(message);
            }
        }
        /// <summary>
        /// 发送信息到客户端
        /// </summary>
        /// <param name="clientKey"></param>
        /// <param name="message"></param>
        public void SendMessage(string clientKey,byte[] message)
        {
            if (this._webSocketDic != null && this._webSocketDic.ContainsKey(clientKey))
            {
                this._webSocketDic[clientKey].Send(message);
            }
        }

        /// <summary>
        /// 处理信息
        /// </summary>
        /// <param name="message"></param>
        private void HandleMessage(string clientKey,string message)
        {
            if (string.IsNullOrEmpty(message))
            {
                return;
            }

            // 串口写入数据
            if (message.Contains(WRITE_FLAG))
            {
                // 将数据报文切割为 COM + DATA
                var data = message.Split(WRITE_FLAG_SEPARATOR, StringSplitOptions.None);
                // 如果串口或者数据为空则不处理
                string com = data[0];
                string text = data[1];
                if (string.IsNullOrEmpty(com) || string.IsNullOrEmpty(text))
                {
                    return;
                }
                // 获取串口实例
                SerialPort writeCom = this._serialPortDic[GetClientSerialKey(clientKey, com)];
                // 写入数据
                SerialPortlUtil.WriteLine(writeCom, text);
                return;
            }

            // 打开串口
            if (message.Contains(OPEN_FLAG))
            {
                // 获取串口
                var data = message.Split(OPEN_FLAG_SEPARATOR, StringSplitOptions.RemoveEmptyEntries);
                // 如果串口为空则不处理
                string com = data[0];
                if (string.IsNullOrEmpty(com))
                {
                    return;
                }
                // 实例化串口
                SerialPort writeCom = SerialPortlUtil.GetSerialPortInstance(com);
                // 打开串口
                writeCom.Open();
                // 缓存串口
                this._serialPortDic.Add(GetClientSerialKey(clientKey, writeCom.PortName), writeCom);
                // 返回信息
                SendMessage(clientKey, string.Format("打开串口成功: {0}", com));
                // 轮询数据
                Task.Run(() => {
                    while (writeCom.IsOpen)
                    {
                        SendMessage(clientKey, writeCom.ReadExisting());
                        Thread.Sleep(500);
                    }
                });
                return;
            }

            // 关闭串口
            if (message.Contains(CLOSE_FLAG))
            {
                // 获取串口
                var data = message.Split(CLOSE_FLAG_SEPARATOR, StringSplitOptions.RemoveEmptyEntries);
                // 如果串口为空则不处理
                string com = data[0];
                if (string.IsNullOrEmpty(com))
                {
                    return;
                }
                // 缓存键名
                string key = GetClientSerialKey(clientKey, com);
                // 获取串口实例
                SerialPort closeCom = this._serialPortDic[key];
                // 关闭串口
                closeCom.Close();
                // 释放资源
                closeCom.Dispose();
                // 移除缓存
                this._serialPortDic.Remove(key);
                // 返回信息
                SendMessage(clientKey, string.Format("关闭串口成功: {0}", com));
                return;
            }
        }

    }
}

三、前端设计与实现

1、界面设计

  1. 顶部为串口的参数选择,提供 OPEN、CLOSE 按钮用于打开、关闭串口;
  2. 中间两个文本框:数据接收区用来输出日志、数据发送区用来输入自定义数据;
  3. 底部提供 SEND、STOP 按钮用于开始发送数据、停止发送数据,勾选自动发送时,会周期发送数据;

2、代码实现

引入 Vue + Vite + ElementUI,新增页面 MainPage.vue ,

<!-- src\components\MainPage.vue -->
<template>
  <el-row :gutter="10">
    <el-col :span="24">
      <el-row justify="space-between" :gutter="10">
        <el-col :span="4">
          <el-select v-model="comValue" clearable placeholder="串口">
            <el-option v-for="item in comOptions" :key="item.value" :label="item.label" :value="item.value" />
          </el-select>
        </el-col>
        <el-col :span="4">
          <el-select v-model="baudRateValue" clearable placeholder="波特率">
            <el-option v-for="item in baudRateOptions" :key="item.value" :label="item.label" :value="item.value" />
          </el-select>
        </el-col>

        <el-col :span="4">
          <el-select v-model="parityValue" clearable placeholder="校验位">
            <el-option v-for="item in parityOptions" :key="item.value" :label="item.label" :value="item.value" />
          </el-select>
        </el-col>
        <el-col :span="4">
          <el-select v-model="dataBitsValue" clearable placeholder="数据位">
            <el-option v-for="item in dataBitsOptions" :key="item.value" :label="item.label" :value="item.value" />
          </el-select>
        </el-col>
        <el-col :span="4">
          <el-select v-model="stopBitsValue" clearable placeholder="停止位">
            <el-option v-for="item in stopBitsOptions" :key="item.value" :label="item.label" :value="item.value" />
          </el-select>
        </el-col>
        <el-col :span="4">
          <div style="text-align: right;">
            <el-button @click="OpenCom" type="primary">OPEN</el-button>
            <el-button @click="CloseCom" type="danger">CLOSE</el-button>
          </div>
        </el-col>
      </el-row>
    </el-col>
  </el-row>

  <el-row :gutter="10">
    <el-col :span="24">
      <h3>数据接收区</h3>
      <el-input ref="logTextarea" v-model="receiveTextarea" :rows="8" type="textarea" placeholder="Log" />
    </el-col>
  </el-row>

  <el-row :gutter="10">
    <el-col :span="24">
      <h3>数据发送区</h3>
      <el-input v-model="sendTextarea" :rows="8" type="textarea" placeholder="Please input" />
    </el-col>
  </el-row>

  <el-row justify="space-between" :gutter="10">
    <el-col :span="2">
      <div class="mt-4">
        <el-checkbox v-model="autoSendChecked" label="自动发送" border />
      </div>
    </el-col>
    <el-col :span="6">
      <el-input v-model="interval" placeholder="1000" type="number">
        <template #prepend>
          周期
        </template>
        <template #append>ms</template>
      </el-input>
    </el-col>
    <el-col :span="12">
    </el-col>
    <el-col :span="4">
      <div style="text-align: right;">
        <el-button @click="sendMessage" type="primary">SEND</el-button>
        <el-button @click="stopTimer" type="danger">STOP</el-button>
      </div>
    </el-col>
  </el-row>
</template>

<script lang="ts" setup>
import { ref, onMounted } from 'vue'
// 串口
const comValue = ref('')
// 波特率
const baudRateValue = ref('')
// 校验位
const parityValue = ref('')
// 数据为
const dataBitsValue = ref('')
// 停止位
const stopBitsValue = ref('')
// 发送文本
const sendTextarea = ref('hello world')
// 接收文本
const receiveTextarea = ref('')
// 自动发送的 timer
let timerId: number
// 自动发送时间间隔
const interval = ref(1000)
// 是否自动发送
const autoSendChecked = ref(false)
// 接收数据的文本框
const logTextarea = ref()
// ws 实例
const socket = ref()

// 串口列表
const comOptions = [
  {
    value: 'COM1',
    label: 'COM1',
  },
  {
    value: 'COM2',
    label: 'COM2',
  },
  {
    value: 'COM3',
    label: 'COM3',
  },
  {
    value: 'COM4',
    label: 'COM4',
  },
  {
    value: 'COM5',
    label: 'COM5',
  },
  {
    value: 'COM6',
    label: 'COM6',
  },
  {
    value: 'COM7',
    label: 'COM7',
  },
  {
    value: 'COM8',
    label: 'COM8',
  },
  {
    value: 'COM9',
    label: 'COM9',
  },
  {
    value: 'COM10',
    label: 'COM10',
  },
]
// 波特率列表
const baudRateOptions = [
  {
    value: '300',
    label: '300',
  },
  {
    value: '600',
    label: '600',
  },
  {
    value: '1200',
    label: '1200',
  },
  {
    value: '2400',
    label: '2400',
  },
  {
    value: '4800',
    label: '4800',
  },
  {
    value: '9600',
    label: '9600',
  },
  {
    value: '19200',
    label: '19200',
  },
  {
    value: '38400',
    label: '38400',
  },
  {
    value: '43000',
    label: '43000',
  },
  {
    value: '56000',
    label: '56000',
  },
  {
    value: '57600',
    label: '57600',
  },
  {
    value: '115200',
    label: '115200',
  },
]
// 校验位列表
const parityOptions = [
  {
    value: '0',
    label: 'None',
  },
  {
    value: '1',
    label: 'Odd',
  },
  {
    value: '2',
    label: 'Even',
  },
  {
    value: '3',
    label: 'Mark',
  },
  {
    value: '4',
    label: 'Space',
  }
]
// 数据位列表
const dataBitsOptions = [
  {
    value: '6',
    label: '6',
  },
  {
    value: '7',
    label: '7',
  },
  {
    value: '8',
    label: '8',
  }
]
// 停止位列表
const stopBitsOptions = [
  {
    value: '0',
    label: '0',
  },
  {
    value: '1',
    label: '1',
  },
  {
    value: '2',
    label: '2',
  },
  {
    value: '3',
    label: '3',
  }
]
// 停止自动发送
const stopTimer = () => {
  if (timerId) {
    // 取消之前设置的定时操作
    console.log(timerId)
    clearTimeout(timerId)
  }
}
// 关闭串口
const CloseCom = () => {
  if (socket.value && socket.value.readyState === WebSocket.OPEN && comValue.value.length > 0) {
    socket.value.send(`##CLOSE##${comValue.value}`);
    stopTimer();
  }
}
// 打开串口
const OpenCom = () => {
  if (comValue.value.length == 0 || baudRateValue.value.length == 0 || parityValue.value.length == 0 || dataBitsValue.value.length == 0 || stopBitsValue.value.length == 0) {
    console.log('OpenCom', '未选择串口参数');
    return;
  }
  if (socket.value && socket.value.readyState === WebSocket.OPEN) {
    socket.value.send(`##OPEN##${comValue.value},${baudRateValue.value},${parityValue.value},${dataBitsValue.value},${stopBitsValue.value}`);
  }
}

const connectToServer = () => {
  socket.value = new WebSocket('ws://127.0.0.1:3000/api');

  socket.value.onopen = () => {
    console.log('WebSocket连接已打开')
    receiveTextarea.value = `${getDateTimeString()} => WebSocket连接已打开\n`;
    socket.value.send(`Authorization: Bearer Gx9epQIqlKTHaV7a57eUkGQ02egvT1FhvD0vblqau1ncmB8ZgyNTu29gM6N+UdgoNkQZyPYx490tekmttk6B6q307rY2P+7ADtJ0L4ZUflCTCrihYdFROtMI0ZdHd/zCOw47FE7n9IsChjpHdIvngJ7cvVCtzejC5E0w1lpH/5/Nb0JT3cEqdi6sI7ybePyq+jg5FQwmOloxKHJ8X1GxqxqVX7LgKBvpZsMrTnyZ9gJeWSbRhZXDe5de0TvOabdMvEPHxFaq3nqOM+seFSk1TLG/LRvAwJizetVV/RWCfz9hAFMZ+f2ThCS547zghuXGRqCNsARa/YumRexehpkNZQ==`);
  };

  socket.value.onmessage = (event: any) => {
    // 滚屏到底部
    logTextarea.value.textarea.scrollTop = logTextarea.value.textarea.scrollHeight;

    if (event.data == '') {
      return;
    }
    console.log('收到消息:', event.data);
    receiveTextarea.value += `${getDateTimeString()} => ${event.data}\n`;
  };

  socket.value.onclose = () => {
    console.log('WebSocket连接已关闭');
    stopTimer();
    receiveTextarea.value += `${getDateTimeString()} => WebSocket连接已关闭\n`;
  };
};

const sendMessage = () => {
  
  console.log(autoSendChecked.value);

  if (socket.value && socket.value.readyState === WebSocket.OPEN) {
    // 自动发送
    if (autoSendChecked.value) {
      timerId = setInterval(() => {
        socket.value.send(`${comValue.value}##WRITE##${sendTextarea.value}`);
      }, interval.value);
    } else {
      // 手动发送
      socket.value.send(`${comValue.value}##WRITE##${sendTextarea.value}`);
    }

  }
};


const getDateTimeString = () => {
  // 创建一个新的Date对象
  const date = new Date();

  // 获取年份、月份和日期
  const year = date.getFullYear();
  const month = ("0" + (date.getMonth() + 1)).slice(-2);
  const day = ("0" + date.getDate()).slice(-2);

  // 获取小时、分钟和秒钟
  const hours = ("0" + date.getHours()).slice(-2);
  const minutes = ("0" + date.getMinutes()).slice(-2);
  const seconds = ("0" + date.getSeconds()).slice(-2);

  // 格式化时间字符串
  const formattedTime = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;

  return formattedTime
}
onMounted(() => {
  connectToServer();
})

</script>

<style>
.el-row {
  margin-bottom: 5px;
}

.el-row:last-child {
  margin-bottom: 0;
}

.el-col {
  border-radius: 4px;
}
</style>

四、运行效果

1、启动 WS 与串口连接

2、串口读写数据

3、停止发送数据

4、关闭串口

五、整合前后端为一个桌面应用

至此,我们可以将后端作为一个服务 SerialPortService 发布到每台电脑,前端界面用 Nginx 发布在一个远程服务器,用户通过浏览器访问到 Web 操作界面,Web 界面通过 WS 访问到本地服务 SerialPortService 。

然而,我们的目标是将前后端整合为一个 EXE 使用。WPF 有自带的浏览器控件,直接使用 WPF 访问 Web 就能实现我们的目标,但这里我们换成 CefSharp 来实现更灵活高效。

1、引入 CefSharp

这里使用的版本是 119.1.20 ,

2、MainWindow.xaml 添加控件挂载点

<Window x:Class="SerialPortDevToolWpfApp.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:SerialPortDevToolWpfApp"
        mc:Ignorable="d"
        Title="MainWindow" 
        WindowState="Normal"
        WindowStartupLocation="CenterScreen">
    <Grid>
        <Grid x:Name="ChromiumWebBrowserGrid"></Grid>
    </Grid>
</Window>

我们添加了一个名字为 ChromiumWebBrowserGrid 的控件在主窗口界面,用于挂载 Browser 控件,

3、MainWindow.xaml.cs 添加 Browser 控件

using CefSharp.Wpf;
using SerialPortDevToolWpfApp.WS;
using System.Threading.Tasks;
using System.Windows;

namespace SerialPortDevToolWpfApp
{
    /// <summary>
    /// MainWindow.xaml 的交互逻辑
    /// </summary>
    public partial class MainWindow : Window
    {

        /// <summary>
        /// ChromiumWebBrowser
        /// </summary>
        private static ChromiumWebBrowser browser;

        public MainWindow()
        {
            InitializeComponent();
            RunWebSocketServer();
            AddChromiumWebBrowser();
        }

        /// <summary>
        /// 运行 WS 服务
        /// </summary>
        private void RunWebSocketServer()
        {
            Task.Run(() =>
            {
                new MyWebSocketServer().Run();
            });
        }

        /// <summary>
        /// Create a new instance in code or add via the designer
        /// </summary>
        private void AddChromiumWebBrowser()
        {

            // 远程 URL
            browser = new ChromiumWebBrowser("http://localhost:5173");
            this.ChromiumWebBrowserGrid.Children.Add(browser);
        }

    }
}

在构造方法中,我们除了运行 WS 服务(RunWebSocketServer),还将 Browser 控件添加到 ChromiumWebBrowserGrid 控件中(AddChromiumWebBrowser),Browser  访问的是远程 URL,

4、运行效果(访问远程网页)

5、访问本地 URL

我们也可以将前端打包为静态资源,然后在后端引入静态资源,直接访问本地 URL,

# 打包前端
npm run build

将 dist 文件夹下所有文件复制到 Frontend 文件夹,并注册代理域,

using CefSharp;
using CefSharp.SchemeHandler;
using CefSharp.Wpf;
using System;
using System.IO;
using System.Windows;

namespace SerialPortDevToolWpfApp
{
    /// <summary>
    /// App.xaml 的交互逻辑
    /// </summary>
    public partial class App : Application
    {

        public App()
        {
            InitCefSettings();
        }

        /// <summary>
        /// 初始化 CEF 配置
        /// </summary>
        private static void InitCefSettings()
        {

#if ANYCPU
            CefRuntime.SubscribeAnyCpuAssemblyResolver();
#endif

            // Pseudo code; you probably need more in your CefSettings also.
            var settings = new CefSettings()
            {
                //By default CefSharp will use an in-memory cache, you need to specify a Cache Folder to persist data
                CachePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "CefSharp\\Cache")
            };

            //Example of setting a command line argument
            //Enables WebRTC
            // - CEF Doesn't currently support permissions on a per browser basis see https://bitbucket.org/chromiumembedded/cef/issues/2582/allow-run-time-handling-of-media-access
            // - CEF Doesn't currently support displaying a UI for media access permissions
            //
            //NOTE: WebRTC Device Id's aren't persisted as they are in Chrome see https://bitbucket.org/chromiumembedded/cef/issues/2064/persist-webrtc-deviceids-across-restart
            settings.CefCommandLineArgs.Add("enable-media-stream");
            //https://peter.sh/experiments/chromium-command-line-switches/#use-fake-ui-for-media-stream
            settings.CefCommandLineArgs.Add("use-fake-ui-for-media-stream");
            //For screen sharing add (see https://bitbucket.org/chromiumembedded/cef/issues/2582/allow-run-time-handling-of-media-access#comment-58677180)
            settings.CefCommandLineArgs.Add("enable-usermedia-screen-capturing");

            // 本地代理域
            settings.RegisterScheme(new CefCustomScheme
            {
                SchemeName = "http",
                DomainName = "serialdevtool.test",
                SchemeHandlerFactory = new FolderSchemeHandlerFactory(rootFolder: @"..\..\..\SerialPortDevToolWpfApp\Frontend",
                            hostName: "serialdevtool.test", //Optional param no hostname/domain checking if null
                            defaultPage: "index.html") //Optional param will default to index.html
            });

            //Perform dependency check to make sure all relevant resources are in our output directory.
            Cef.Initialize(settings, performDependencyCheck: true, browserProcessHandler: null);

        }

    }
}

此时,访问 http://serialdevtool.test 就是访问 Frontend 文件夹下的 index.html 网页,接着配置 Browser 的 URL,

        /// <summary>
        /// Create a new instance in code or add via the designer
        /// </summary>
        private void AddChromiumWebBrowser()
        {
            // 本地代理域
            browser = new ChromiumWebBrowser("http://serialdevtool.test");
            // 远程 URL
            // browser = new ChromiumWebBrowser("http://localhost:5173");
            this.ChromiumWebBrowserGrid.Children.Add(browser);
        }

运行效果如下,

6、运行效果(访问本地资源)

六、应用多开(补充)

1、目前的不足

仅支持单例应用运行,多应用运行会存在 WS 连接问题,试想这样一个场景:

  • Step 1、应用 A 访问串口 1,应用 A 启动 WS 服务器;
  • Step 2、应用 B 访问串口 2,应用 B 不启动 WS 服务器,访问应用 A 的 WS 服务器通讯;
  • Step 3、应用 A 关闭,应用 B 运行,WS 通讯失败;

2、解决对策

  1. 单例应用支持多标签页实现多个窗口;
  2. 将端口 3000 ~ 3099 作为应用端口,每次应用启动时锁住一个端口号,关闭时解锁;

这样无论在单个应用多开窗口还是启动多个应用都不会存在 WS 连接问题。

3、引入 Sqlite 作为嵌入式缓存

安装 System.Data.Sqlite 包,这里使用的版本是 1.0.118,

4、缓存逻辑封装

主要封装五个功能,

  1. 若无缓存表,则创建表
  2. 若无数据,则新增记录
  3. 获取一个可用端口
  4. 锁定端口
  5. 解锁端口
using SerialPortDevToolWpfApp.Constant;
using System;
using System.Data.SQLite;
using System.Text;

namespace SerialPortDevToolWpfApp.Utils
{
    public class MySqliteUtil
    {
        /// <summary>
        /// 生成连接字符串
        /// </summary>
        /// <returns></returns>
        private static string CreateConnectionString()
        {
            SQLiteConnectionStringBuilder connectionString = new SQLiteConnectionStringBuilder();
            connectionString.DataSource = AppDomain.CurrentDomain.SetupInformation.ApplicationBase + SystemConstant.DATABASE_NAME;
            return connectionString.ToString();
        }

        /// <summary>
        /// 创建端口缓存表
        /// Id 为主键,AppWsPort 为 WS 端口,IsUsing 是否在使用
        /// </summary>
        public static void CreatePortCacheTable()
        {
            string connectionString = CreateConnectionString();
            using (SQLiteConnection connection = new SQLiteConnection(connectionString))
            {
                // 打开连接
                connection.Open();

                // 创建表
                using (SQLiteCommand command = new SQLiteCommand(SystemConstant.CREATE_PORT_CACHE_TABLE_SQL, connection))
                {
                    command.ExecuteNonQuery();
                }

                // 新增数据
                StringBuilder cmd = new StringBuilder();
                for (int i = 0; i < SystemConstant.PORT_RANGE; i++)
                {
                    cmd = cmd.AppendFormat(SystemConstant.INSERT_PORT_CACHE_TEMPLATE_SQL, SystemConstant.START_PORT + i, 0, SystemConstant.START_PORT + i);
                }
                using (SQLiteCommand command = new SQLiteCommand(cmd.ToString(), connection))
                {
                    command.ExecuteNonQuery();
                }

                // 关闭连接
                connection.Close();
            }
        }

        /// <summary>
        /// 获取一个可用的端口
        /// </summary>
        /// <returns></returns>
        public static int GetAvailablePort()
        {
            int port = 0;
            string connectionString = CreateConnectionString();
            using (SQLiteConnection connection = new SQLiteConnection(connectionString))
            {
                connection.Open();
                // 查询数据
                using (SQLiteCommand command = new SQLiteCommand(SystemConstant.GET_AVAILABLE_PORT_SQL, connection))
                {
                    using (SQLiteDataReader reader = command.ExecuteReader())
                    {
                        if (reader.Read())
                        {
                            port = Convert.ToInt32(reader[SystemConstant.ROW_APP_WS_PORT]);
                        }
                    }
                    command.Dispose();
                }
                connection.Close();
            }
            return port;
        }

        /// <summary>
        /// 解锁或者锁定一个端口
        /// </summary>
        /// <param name="toLock"> true 锁定,false 解锁</param>
        /// <param name="port"></param>
        public static void LockOrUnLockPort(bool toLock,int port)
        {
            string connectionString = CreateConnectionString();
            using (SQLiteConnection connection = new SQLiteConnection(connectionString))
            {
                connection.Open();

                string cmd = toLock ? SystemConstant.LOCK_PORT_SQL : SystemConstant.UNLOCK_PORT_SQL;

                // 更新数据
                using (SQLiteCommand command = new SQLiteCommand(cmd, connection))
                {
                    command.Parameters.AddWithValue(SystemConstant.SQL_PARAM_APP_WS_PORT, port);
                    command.ExecuteNonQuery();
                }

                connection.Close();
            }
        }
    }
}

常量类、全局参数类封装,


namespace SerialPortDevToolWpfApp.Constant
{
    public class SystemConstant
    {
        /// <summary>
        /// 新增模板 SQL
        /// </summary>
        public const string INSERT_PORT_CACHE_TEMPLATE_SQL = "INSERT INTO PortCache (AppWsPort, IsUsing) SELECT {0},{1} WHERE NOT EXISTS (SELECT 1 FROM PortCache WHERE AppWsPort={2});";
        /// <summary>
        /// 创建表 SQL
        /// </summary>
        public const string CREATE_PORT_CACHE_TABLE_SQL = "CREATE TABLE IF NOT EXISTS PortCache (Id INTEGER PRIMARY KEY, AppWsPort INTEGER, IsUsing INTEGER);";
        /// <summary>
        /// 获取一个可用的端口 SQL
        /// </summary>
        public const string GET_AVAILABLE_PORT_SQL = "SELECT AppWsPort FROM PortCache WHERE IsUsing = 0 LIMIT 1;";
        /// <summary>
        /// 锁定端口 SQL
        /// </summary>
        public const string LOCK_PORT_SQL = "UPDATE PortCache SET IsUsing = 1 WHERE AppWsPort=@AppWsPort;";
        /// <summary>
        /// 解锁端口 SQL
        /// </summary>
        public const string UNLOCK_PORT_SQL = "UPDATE PortCache SET IsUsing = 0 WHERE AppWsPort=@AppWsPort;";

        /// <summary>
        /// 数据库名
        /// </summary>
        public const string DATABASE_NAME = "IkunDB.db";

        /// <summary>
        /// 端口范围
        /// </summary>
        public const int PORT_RANGE = 100;

        /// <summary>
        /// 开始端口
        /// </summary>
        public const int START_PORT = 3000;

        /// <summary>
        /// 数据表字段
        /// </summary>
        public const string ROW_APP_WS_PORT = "AppWsPort";

        /// <summary>
        /// SQL 参数
        /// </summary>
        public const string SQL_PARAM_APP_WS_PORT = "@AppWsPort";
    }
}

namespace SerialPortDevToolWpfApp.Parameters
{
    /// <summary>
    /// app 全局参数
    /// </summary>
    public class GlobalParameters
    {
        /// <summary>
        /// WS 端口
        /// </summary>
        public static int WsPort { get; set; }
    }
}

5、修改 WS 启动逻辑

        /// <summary>
        /// WS URL
        /// </summary>
        /// <returns></returns>
        private string GetWsLocationString()
        {
            int port = MySqliteUtil.GetAvailablePort();

            if (port == 0)
            {
                return "";
            }

            // 锁定当前端口
            MySqliteUtil.LockOrUnLockPort(true, port);
            // 全局端口参数
            GlobalParameters.WsPort = port;

            return string.Format(@"ws://127.0.0.1:{0}", port);
        }

        /// <summary>
        /// 运行 WS 服务器
        /// </summary>
        public void Run()
        {
            FleckLog.Level = LogLevel.Debug;

            string location = GetWsLocationString();

            if (string.IsNullOrEmpty(location))
            {
                Debug.WriteLine("端口 3000 - 3099 内暂无可用端口,WS 启动失败!");
                return;
            }

            var server = new WebSocketServer(location);
            Debug.WriteLine(string.Format("WebSocketServer Port: {0}", server.Port));
        
            // 略
            ......
        }

6、导出 WS 端口

新增 GlobalParametersUtil 类,导出 GetGlobalWsPort 方法,

using SerialPortDevToolWpfApp.Parameters;

namespace SerialPortDevToolWpfApp.Utils
{
    public class GlobalParametersUtil
    {
        /// <summary>
        /// 返回全局 WS 端口
        /// </summary>
        /// <returns></returns>
        public int GetGlobalWsPort()
        {
            return GlobalParameters.WsPort;
        }
    }
}

修改 MainWindow.xaml.cs,新增 ExposeDotnetClass 导出 GlobalParametersUtil 实例,并重写 OnClosing 方法,

using CefSharp;
using CefSharp.JavascriptBinding;
using CefSharp.Wpf;
using SerialPortDevToolWpfApp.Parameters;
using SerialPortDevToolWpfApp.Utils;
using SerialPortDevToolWpfApp.WS;
using System.ComponentModel;
using System.Diagnostics;
using System.Threading.Tasks;
using System.Windows;

namespace SerialPortDevToolWpfApp
{
    /// <summary>
    /// MainWindow.xaml 的交互逻辑
    /// </summary>
    public partial class MainWindow : Window
    {

        private static bool IsSetNameConverter = false;

        /// <summary>
        /// ChromiumWebBrowser
        /// </summary>
        private static ChromiumWebBrowser browser;

        public MainWindow()
        {
            InitializeComponent();
            RunWebSocketServer();
            // 最大化窗口
            this.WindowState = WindowState.Maximized;
            // 添加浏览器控件
            AddChromiumWebBrowser();
        }

        /// <summary>
        /// 运行 WS 服务
        /// </summary>
        private void RunWebSocketServer()
        {
            Task.Run(() =>
            {
                new MyWebSocketServer().Run();
            });
        }

        /// <summary>
        /// Create a new instance in code or add via the designer
        /// </summary>
        private void AddChromiumWebBrowser()
        {
            // 本地代理域
            browser = new ChromiumWebBrowser("http://serialdevtool.test");
            // 远程 URL
            // browser = new ChromiumWebBrowser("http://localhost:5173");
            // 导出 .NET 方法
            ExposeDotnetClass();
            // 将 browser 挂载到页面
            this.ChromiumWebBrowserGrid.Children.Add(browser);
        }


        /// <summary>
        /// 导出类方法
        /// </summary>
        public static void ExposeDotnetClass()
        {
            browser.JavascriptObjectRepository.ResolveObject += (sender, e) =>
            {
                // 注册 GlobalParametersUtil 实例
                DoRegisterDotNetFunc(e.ObjectRepository, e.ObjectName, "globalParametersUtil", new GlobalParametersUtil());

                // 注册其他实例 ...
            };

            browser.JavascriptObjectRepository.ObjectBoundInJavascript += (sender, e) =>
            {
                var name = e.ObjectName;
                Debug.WriteLine($"Object {e.ObjectName} was bound successfully.");
            };

        }
        /// <summary>
        /// 注册 DoNet 实例
        /// </summary>
        /// <param name = "repo" > IJavascriptObjectRepository </ param >
        /// < param name="eventObjectName">事件对象名</param>
        /// <param name = "funcName" > 方法名 </ param >
        /// < param name="objectToBind">需要绑定的DotNet对象</param>
        private static void DoRegisterDotNetFunc(IJavascriptObjectRepository repo, string eventObjectName, string funcName, object objectToBind)
        {

            if (eventObjectName.Equals(funcName))
            {
                if (!IsSetNameConverter)
                {
                    repo.NameConverter = new CamelCaseJavascriptNameConverter();
                    IsSetNameConverter = true;
                }

                BindingOptions bindingOptions = null;
                bindingOptions = BindingOptions.DefaultBinder;

                repo.Register(funcName, objectToBind, isAsync: true, options: bindingOptions);
            }
        }

        /// <summary>
        /// 重写关闭方法
        /// </summary>
        /// <param name="e"></param>
        protected override void OnClosing(CancelEventArgs e)
        {
            // 解锁 WS 端口
            MySqliteUtil.LockOrUnLockPort(false, GlobalParameters.WsPort);
            // 释放资源
            browser.Dispose();
            Cef.Shutdown();
        }

    }
}

7、app 启动调整

除了初始化 CEF 配置,还要初始化数据库缓存,

        public App()
        {
            InitCefSettings();
            MySqliteUtil.CreatePortCacheTable();
        }

8、前端 api 封装

// src\api\GlobalParametersUtil.ts
export const getGlobalWsPort = async (): Promise<any> => {
    await CefSharp.BindObjectAsync("globalParametersUtil")
    return globalParametersUtil.getGlobalWsPort()
}

9、调整组件 WS 连接

// src\components\MainPage.vue

import { getGlobalWsPort } from '../api/GlobalParametersUtil.ts'

onMounted(() => {

  getGlobalWsPort().then((port: any) => {
    connectToServer(`ws://127.0.0.1:${port}/api`);
  })

})

/**组件销毁前关闭串口 */
onBeforeUnmount(() => {
  CloseCom();
})

10、新增 MyTabs 组件

通过点击界面上的按钮 “+” 来新增一个相同的 Tab 页面,

// src\components\MyTabs.vue
<template>
    <el-tabs v-model="editableTabsValue" type="card" editable class="demo-tabs" @edit="handleTabsEdit">
        <el-tab-pane v-for="item in editableTabs" :key="item.name" :label="item.title" :name="item.name">
            <MainPage />
        </el-tab-pane>
    </el-tabs>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import type { TabPaneName } from 'element-plus'
import MainPage from './MainPage.vue'

let tabIndex = 1
const editableTabsValue = ref('1')
const editableTabs = ref([
    {
        title: 'Tab 1',
        name: '1',
        content: 'Tab 1 content',
    },
])

const handleTabsEdit = (
    targetName: TabPaneName | undefined,
    action: 'remove' | 'add'
) => {
    if (action === 'add') {
        const newTabName = `${++tabIndex}`
        editableTabs.value.push({
            title: `T${newTabName}`,
            name: newTabName,
            content: 'New Tab content',
        })
        editableTabsValue.value = newTabName
    } else if (action === 'remove') {
        const tabs = editableTabs.value
        let activeName = editableTabsValue.value
        if (activeName === targetName) {
            tabs.forEach((tab, index) => {
                if (tab.name === targetName) {
                    const nextTab = tabs[index + 1] || tabs[index - 1]
                    if (nextTab) {
                        activeName = nextTab.name
                    }
                }
            })
        }

        editableTabsValue.value = activeName
        editableTabs.value = tabs.filter((tab) => tab.name !== targetName)
    }
}
</script>
<style>
.demo-tabs>.el-tabs__content {
    color: #6b778c;
}
</style>
  

11、运行效果

12、实现要点

通过 Vue/Wpf 组件生命周期管理 WS 打开与关闭;

七、数据轮询改进(补充)

串口读取数据是通过 Task 任务在 While 循环里轮询来实现,这种方式如同操作系统中的 Selector ,那不妨借鉴 Netty 的思想,可以通过异步事件驱动实现高效读取数据;

1、组合串口与 WS

MySerialWebSocket 类组合两个关键成员:IWebSocketConnection、SerialPort,逻辑上先赋值 IWebSocketConnection ,再将 SerialPort 绑定到 IWebSocketConnection。自定义实现 SerialDataReceivedEventHandler ,在串口接收了数据之后读取数据,通过 WS 发送到前端。

using Fleck;
using System;
using System.Diagnostics;
using System.IO.Ports;
using System.Threading.Tasks;

namespace SerialPortDevToolWpfApp.WS
{
    /// <summary>
    /// 基于异步事件驱动实现的串口工具类
    /// </summary>
    public class MySerialWebSocket
    {
        /// <summary>
        /// 默认超时时间,单位 ms
        /// </summary>
        private static readonly int DEFAULT_TIMEOUT = 500;
        /// <summary>
        /// 默认COM口
        /// </summary>
        private static readonly string DEFAULT_COM = "COM1";
        /// <summary>
        /// 默认波特率
        /// </summary>
        private static readonly int DEFAULT_BAUDRATE = 9600;
        /// <summary>
        /// 默认校验位
        /// </summary>        
        private static readonly Parity DEFAULT_PARITY = Parity.None;
        /// <summary>
        /// 默认数据位
        /// </summary>
        private static readonly int DEFAULT_DATABITS = 8;
        /// <summary>
        /// 默认停止位
        /// </summary>
        private static readonly StopBits DEFAULT_STOPBITS = StopBits.One;
        /// <summary>
        /// 串口实例
        /// </summary>
        private SerialPort _serialPort;

        /// <summary>
        /// ws 实例
        /// </summary>
        private IWebSocketConnection _webSocket;

        /// <summary>
        /// 构造方法
        /// </summary>
        public MySerialWebSocket(IWebSocketConnection webSocket)
        {
            this._webSocket = webSocket;
        }

        /// <summary>
        /// 创建默认串口实例
        /// </summary>
        public void CreateDefaultSerialPortInstance()
        {
            CreateSerialPortInstance(DEFAULT_COM);
        }

        /// <summary>
        /// 创建串口实例
        /// </summary>
        /// <param name="com"></param>
        /// <returns></returns>
        public void CreateSerialPortInstance(string com)
        {
            // COM1,9600,0,8,1
            if (com.Contains(","))
            {
                string[] comParams = com.Split(new string[] { "," }, StringSplitOptions.None);

                this._serialPort = new SerialPort(comParams[0], int.Parse(comParams[1]), GetParity(comParams[2]), int.Parse(comParams[3]), GetStopBits(comParams[4]))
                {
                    ReadTimeout = DEFAULT_TIMEOUT,
                    WriteTimeout = DEFAULT_TIMEOUT
                };

            }
            else
            {
                // COM1
                this._serialPort = new SerialPort(com, DEFAULT_BAUDRATE, DEFAULT_PARITY, DEFAULT_DATABITS, DEFAULT_STOPBITS)
                {
                    ReadTimeout = DEFAULT_TIMEOUT,
                    WriteTimeout = DEFAULT_TIMEOUT
                };
            }

            // 数据接收处理器,将 WS 绑定串口
            this._serialPort.DataReceived += SerialPortDataReceived;
        }

        /// <summary>
        /// 解析停止位
        /// </summary>
        /// <param name="stopBits"></param>
        /// <returns></returns>
        public static StopBits GetStopBits(string stopBits)
        {
            switch (stopBits)
            {
                case "0":
                    {
                        return StopBits.None;
                    }
                case "1":
                    {
                        return StopBits.One;
                    }
                case "2":
                    {
                        return StopBits.Two;
                    }
                case "3":
                    {
                        return StopBits.OnePointFive;
                    }
                default:
                    return StopBits.One;
            }
        }

        /// <summary>
        /// 解析校验位
        /// </summary>
        /// <param name="parity"></param>
        /// <returns></returns>
        public static Parity GetParity(string parity)
        {
            switch (parity)
            {
                case "0":
                    {
                        return Parity.None;
                    }
                case "1":
                    {
                        return Parity.Odd;
                    }
                case "2":
                    {
                        return Parity.Even;
                    }
                case "3":
                    {
                        return Parity.Mark;
                    }
                case "4":
                    {
                        return Parity.Space;
                    }
                default:
                    return Parity.None;
            }
        }

        /// <summary>
        /// ws 发送信息
        /// </summary>
        /// <param name="message"></param>
        public void WebSocketSendMessage(string message)
        {
            this._webSocket.Send(message);
        }

        /// <summary>
        /// 打开串口
        /// </summary>
        public void OpenSerialPort(string com)
        {
            if(this._serialPort != null)
            {
                DisposeSerialPort();
            }

            if (string.IsNullOrEmpty(com))
            {
                CreateDefaultSerialPortInstance();
            }
            else
            {
                CreateSerialPortInstance(com);
            }
            this._serialPort.Open();
        }

        /// <summary>
        /// 释放串口
        /// </summary>
        public void DisposeSerialPort()
        {
            this._serialPort.Close();
            this._serialPort.Dispose();
        }

        /// <summary>
        /// 发送数据
        /// </summary>
        /// <param name="data"></param>
        /// <returns></returns>
        public async Task SerialPortSendDataAsync(string data)
        {
            try
            {
                await Task.Run(() => _serialPort.WriteLine(data));
            }
            catch (Exception ex)
            {
                Debug.WriteLine(string.Format("WriteLine Exception: {0}", ex.Message));
            }
        }

        /// <summary>
        /// 处理接收到的数据
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void SerialPortDataReceived(object sender, SerialDataReceivedEventArgs e)
        {
            try
            {
                string receivedData = this._serialPort.ReadLine();
                this.WebSocketSendMessage(receivedData);
            }
            catch (Exception ex)
            {
                Debug.WriteLine(string.Format("ReadLine Exception: {0}", ex.Message));
            }
        }
    }
}

2、WS 服务器类修改

AsyncTaskWsServer 对应 MyWebSocketServer 修改缓存逻辑、打开/关闭串口逻辑、串口写入数据逻辑,

using Fleck;
using SerialPortDevToolWpfApp.Parameters;
using SerialPortDevToolWpfApp.Utils;
using System;
using System.Collections.Generic;
using System.Diagnostics;

namespace SerialPortDevToolWpfApp.WS
{
    public class AsyncTaskWsServer
    {
        /// <summary>
        /// 写标志
        /// </summary>
        private const string WRITE_FLAG = "##WRITE##";
        private readonly string[] WRITE_FLAG_SEPARATOR = new string[] { WRITE_FLAG };

        /// <summary>
        /// 打开串口标志
        /// </summary>
        private const string OPEN_FLAG = "##OPEN##";
        private readonly string[] OPEN_FLAG_SEPARATOR = new string[] { OPEN_FLAG };
        /// <summary>
        /// 关闭串口标志
        /// </summary>
        private const string CLOSE_FLAG = "##CLOSE##";
        private readonly string[] CLOSE_FLAG_SEPARATOR = new string[] { CLOSE_FLAG };

        /// <summary>
        /// 当前连接的实例缓存
        /// </summary>
        private Dictionary<string, MySerialWebSocket> _myWstDic;

        public AsyncTaskWsServer()
        {
            this._myWstDic = new Dictionary<string, MySerialWebSocket>();
        }

        /// <summary>
        /// WS URL
        /// </summary>
        /// <returns></returns>
        private string GetWsLocationString()
        {
            int port = MySqliteUtil.GetAvailablePort();

            if (port == 0)
            {
                return "";
            }

            // 锁定当前端口
            MySqliteUtil.LockOrUnLockPort(true, port);
            // 全局端口参数
            GlobalParameters.WsPort = port;

            return string.Format(@"ws://127.0.0.1:{0}", port);
        }

        /// <summary>
        /// 运行 WS 服务器
        /// </summary>
        public void Run()
        {
            FleckLog.Level = LogLevel.Debug;

            string location = GetWsLocationString();

            if (string.IsNullOrEmpty(location))
            {
                Debug.WriteLine("端口 3000 - 3099 内暂无可用端口,WS 启动失败!");
                return;
            }

            var server = new WebSocketServer(location);
            Debug.WriteLine(string.Format("WebSocketServer Port: {0}", server.Port));

            server.Start(socket =>
            {
                // 建立连接
                socket.OnOpen = () =>
                {
                    Debug.WriteLine("客户端连接成功");
                    // 获取请求路径参数
                    string pathParams = GetPathParams(socket.ConnectionInfo.Path);
                    Debug.WriteLine($"WebSocket opened with path params: {pathParams}");
                };
                // 关闭连接
                socket.OnClose = () =>
                {
                    Debug.WriteLine("客户端已经关闭");
                    // 移除缓存 socket
                    this._myWstDic.Remove(GetClientKey(socket.ConnectionInfo.ClientIpAddress, socket.ConnectionInfo.ClientPort));
                };
                // 收到消息
                socket.OnMessage = message =>
                {
                    Debug.WriteLine(string.Format("收到客户端信息: {0}", message));

                    // 鉴权
                    if (message.StartsWith("Authorization: Bearer"))
                    {
                        // 进行身份验证逻辑,检查 token 令牌的有效性
                        string token = message.Split(' ')[2];
                        if (ValidateToken(token))
                        {
                            // 身份验证通过,处理业务逻辑
                            // 新增实例缓存
                            this._myWstDic.Add(GetClientKey(socket.ConnectionInfo.ClientIpAddress, socket.ConnectionInfo.ClientPort), new MySerialWebSocket(socket));
                            // 返回信息
                            socket.Send("Authorization: PASS");
                        }
                        else
                        {
                            // 身份验证失败,关闭连接
                            socket.Close();
                        }
                        return;
                    }


                    // 处理信息
                    HandleMessage(GetClientKey(socket.ConnectionInfo.ClientIpAddress, socket.ConnectionInfo.ClientPort), message);


                };
                // 发生错误
                socket.OnError = exception =>
                {
                    Debug.WriteLine(string.Format("发生错误: {0}", exception.Message));
                };
            });

            Debug.WriteLine("WS服务器已启动");
        }
        /// <summary>
        /// 获取客户端 ip + port
        /// </summary>
        /// <param name="socket"></param>
        /// <returns></returns>
        private string GetClientKey(string ip, int port)
        {
            return string.Format("{0}:{1}", ip, port);
        }
        /// <summary>
        /// 检验 token 是否有效
        /// </summary>
        /// <param name="token"></param>
        /// <returns></returns>
        private bool ValidateToken(string token)
        {
            return true;
        }
        /// <summary>
        /// 解析参数
        /// </summary>
        /// <param name="path"></param>
        /// <returns></returns>
        private string GetPathParams(string path)
        {
            // 解析路径参数
            int startIndex = path.LastIndexOf('/') + 1;
            return path.Substring(startIndex);
        }

        /// <summary>
        /// 发送信息到客户端
        /// </summary>
        /// <param name="clientKey"></param>
        /// <param name="message"></param>
        public void SendMessage(string clientKey, string message)
        {
            if (this._myWstDic != null && this._myWstDic.ContainsKey(clientKey))
            {
                Debug.WriteLine(string.Format("发送给客户端{0}: {1}", clientKey, message));
                this._myWstDic[clientKey].WebSocketSendMessage(message);
            }
        }

        /// <summary>
        /// 处理信息
        /// </summary>
        /// <param name="message"></param>
        private async void HandleMessage(string clientKey, string message)
        {
            if (string.IsNullOrEmpty(message))
            {
                return;
            }

            // 串口写入数据
            if (message.Contains(WRITE_FLAG))
            {
                // 将数据报文切割为 COM + DATA
                var data = message.Split(WRITE_FLAG_SEPARATOR, StringSplitOptions.None);
                // 如果串口或者数据为空则不处理
                string com = data[0];
                string text = data[1];
                if (string.IsNullOrEmpty(com) || string.IsNullOrEmpty(text))
                {
                    return;
                }
                // 获取串口实例并写入数据
                await this._myWstDic[clientKey].SerialPortSendDataAsync(text);
                return;
            }

            // 打开串口
            if (message.Contains(OPEN_FLAG))
            {
                // 获取串口
                var data = message.Split(OPEN_FLAG_SEPARATOR, StringSplitOptions.RemoveEmptyEntries);
                // 如果串口为空则不处理
                string com = data[0];
                if (string.IsNullOrEmpty(com))
                {
                    return;
                }
                // 实例化并打开串口
                this._myWstDic[clientKey].OpenSerialPort(com);
                // 返回信息
                SendMessage(clientKey, string.Format("打开串口成功: {0}", com));
                return;
            }

            // 关闭串口
            if (message.Contains(CLOSE_FLAG))
            {
                // 获取串口
                var data = message.Split(CLOSE_FLAG_SEPARATOR, StringSplitOptions.RemoveEmptyEntries);
                // 如果串口为空则不处理
                string com = data[0];
                if (string.IsNullOrEmpty(com))
                {
                    return;
                }
                // 关闭串口并释放资源
                this._myWstDic[clientKey].DisposeSerialPort();
                // 返回信息
                SendMessage(clientKey, string.Format("关闭串口成功: {0}", com));
                return;
            }
        }

    }
}

3、运行效果

八、更多功能(待开发)

  1. 响应式布局
  2. 十六进制显示/发送
  3. 保存接收的数据
  4. 发送 txt 文件
  5. 发送/接收计数器

参考

使用 ElementUI 组件构建 Window 桌面应用探索与实践(WPF)-CSDN博客文章浏览阅读785次。基于 CEF 实现 Vue + Vite + ElementUI 组件构建 Window 桌面应用(WPF)https://blog.csdn.net/weixin_47560078/article/details/134189591

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mfbz.cn/a/259385.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

Python (十二) NumPy操作

程序员的公众号&#xff1a;源1024&#xff0c;获取更多资料&#xff0c;无加密无套路&#xff01; 最近整理了一波电子书籍资料&#xff0c;包含《Effective Java中文版 第2版》《深入JAVA虚拟机》&#xff0c;《重构改善既有代码设计》&#xff0c;《MySQL高性能-第3版》&…

程序员的20大Git面试问题及答案

文章目录 1.什么是Git&#xff1f;2.Git 工作流程3.在 Git 中提交的命令是什么&#xff1f;4.什么是 Git 中的“裸存储库”&#xff1f;5.Git 是用什么语言编写的&#xff1f;6.在Git中&#xff0c;你如何还原已经 push 并公开的提交&#xff1f;7.git pull 和 git fetch 有什么…

计算机网络(3):数据链路层

数据链路层属于计算机网络的低层。 数据链路层使用的信道主要有以下两种类型&#xff1a; (1)点对点信道。这种信道使用一对一的点对点通信方式。 (2)广播信道。这种信道使用一对多的广播通信方式。广播信道上连接的主机很多&#xff0c;因此必须使用专用的共享信道协议来协调这…

制作PPT找了一个校徽是方形的,如何裁剪为圆形的。

问题描述&#xff1a;制作PPT找了一个校徽是方形的&#xff0c;如何裁剪为圆形的。 问题解决&#xff1a;使用一个在线圆形裁剪软件即可。 网址为&#xff1a; https://crop-circle.imageonline.co/cn/#google_vignette

css实现边框彩虹跑马灯效果

效果展示 代码实战 <!DOCTYPE html> <html lang"en"><head><meta charset"UTF-8"><meta http-equiv"X-UA-Compatible" content"IEedge"><meta name"viewport" content"widthdevice-…

VWAP 订单的最佳执行方法:随机控制法

数量技术宅团队在CSDN学院推出了量化投资系列课程 欢迎有兴趣系统学习量化投资的同学&#xff0c;点击下方链接报名&#xff1a; 量化投资速成营&#xff08;入门课程&#xff09; Python股票量化投资 Python期货量化投资 Python数字货币量化投资 C语言CTP期货交易系统开…

软件设计模式:UML类图

文章目录 前言一、&#x1f4d6;设计模式概述1.软件设计模式的产生背景2.软件设计模式3.设计模式分类 二、&#x1f4e3;UML图1.类图概述2.类的表示法3.类与类之间的关系关联关系&#xff08;1&#xff09;单向关联&#xff08;2&#xff09;双向关联&#xff08;3&#xff09;…

【物联网无线通信技术】WiFi从理论到实践(ESP8266)

文章从理论基础到具体实现完整的介绍了最常见的物联网无线通信技术&#xff1a;WiFi。 文章首先介绍了WiFi这种无线通信技术的一些基本概念&#xff0c;并针对其使用的802.11协议的基本概念与其定义的无线通信连接建立过程进行了简单的介绍&#xff0c;然后对WiFi开发常常涉及的…

代码提交规范-ESLint+Prettier+husky+Commitlint

代码提交规范-ESLintPrettierhuskyCommitlint 配置eslint &#xff08;3步&#xff09;配置prettier(4步)1.安装配置prettier2.设置忽略文件 .prettierignore3.处理eslint冲突4. 配置vscode 的settings.json husky安装并配置lint-staged&#xff08;3步&#xff09;安装配置com…

网线的制作集线器交换机路由器的配置--含思维导图

&#x1f3ac; 艳艳耶✌️&#xff1a;个人主页 &#x1f525; 个人专栏 &#xff1a;《产品经理如何画泳道图&流程图》 ⛺️ 越努力 &#xff0c;越幸运 一、网线的制作 1、网线的材料有哪些&#xff1f; 网线 网线是一种用于传输数据信号的电缆&#xff0c;广泛应…

【STM32工具篇】使用CLion开发STM32

本文主要记录使用CLion开发STM32&#xff0c;并调试相关功能 使用的CLion版本&#xff1a;2023.3.1 CLion嵌入式配置教程&#xff1a;STM32CubeMX项目 |CLion 文档 (jetbrains.com) OpenOCD官网下载&#xff1a;Download OpenOCD for Windows (gnutoolchains.com) GNU ARM工…

Java 栈和队列的交互实现

文章目录 队列和栈的区别一.用队列模拟实现栈1.1入栈1.2出栈1.3返回栈顶元素1.4判断栈是否为空 二.用栈模拟实现队列2.1 入队2.2出队2.3peek2.4判断队列是否为空 三.完整代码3.1 队列模拟实现栈3.2栈模拟实现队列 队列和栈的区别 栈和队列都是常用的数据结构&#xff0c;它们的…

Zookeeper-快速开始

Zookeeper介绍 简介&#xff1a;ZooKeeper 是一个开源的分布式协调框架&#xff0c;是Apache Hadoop 的一个子项目&#xff0c;主要用来解决分布式集群中应用系统的一致性问题。 设计目标&#xff1a;将那些复杂且容易出错的分布式一致性服务封装起来&#xff0c;构成一个高效…

Java中四种引用类型(强、软、弱、虚)

目录 引言 强引用&#xff08;Strong References&#xff09; 软引用&#xff08;Soft References&#xff09; 弱引用&#xff08;Weak References&#xff09; 虚引用&#xff08;Phantom References&#xff09; 引用类型的应用场景 总结 引言 Java中的引用类型是管理…

【漏洞复现】CVE-2023-6895 IP网络对讲广播系统远程命令执行

漏洞描述 杭州海康威视数字技术有限公司IP网络对讲广播系统。 海康威视对讲广播系统3.0.3_20201113_RELEASE(HIK)存在漏洞。它已被宣布为关键。该漏洞影响文件/php/ping.php 的未知代码。使用输入 netstat -ano 操作参数 jsondata[ip] 会导致 os 命令注入。 开发语言:PHP 开…

计算机组件操作系统BIOS的相关知识思维导图

&#x1f3ac; 艳艳耶✌️&#xff1a;个人主页 &#x1f525; 个人专栏 &#xff1a;《产品经理如何画泳道图&流程图》 ⛺️ 越努力 &#xff0c;越幸运 目录 一、运维实施工程师需要具备的知识 1、运维工程师、实施工程师是啥&#xff1f; 2、运维工程师、实施工…

[DNS网络] 网页无法打开、显示不全、加载卡顿缓慢 | 解决方案

[网络故障] 网页无法打开、显示不全、加载卡顿缓慢 | 解决方案 问题描述 最近&#xff0c;我在使用CSDN插件浏览 MOOC 网站时&#xff0c;遇到了一些网络故障。具体表现为&#xff1a; MOOC 中国大学慕课网&#xff1a;www.icourse163.org点击CSDN插件首页的 MOOC&#xff08…

gitcode邀请协作人员

项目首页 点击项目设置 点击项目成员设置--生成邀请链接 设置权限、是否需要审核、成员有效时间、邀请链接有效时间&#xff08;不设置时间就是永久有效&#xff09; 点击创建链接 点击复制分享给别人加入即可

自动化测试工具-Selenium:WebDriver的API/方法使用全解

我们上一篇文章介绍了Selenium的三大组件&#xff0c;其中介绍了WebDriver是最重要的组件。在这里&#xff0c;我们将看到WebDriver常用的API/方法&#xff08;注&#xff1a;这里使用Python语言来进行演示&#xff09;。 1. WebDriver创建 打开VSCode&#xff0c;我们首先引…

windows下wsl(ubuntu)ldconfig报错

错误 sudo ldconfig /sbin/ldconfig.real: Cant link /usr/lib/wsl/lib/libnvoptix_loader.so.1 to libnvoptix.so.1 /sbin/ldconfig.real: /usr/lib/wsl/lib/libcuda.so.1 is not a symbolic link解决&#xff1a; 处理 sudo ldconfig 报错 libcuda.so.1 is not a symbolic …
最新文章