通过TCP将USB串行设备共享给多个客户端

介绍

早在2020年,我就开始了一个新的爱好,我确实在论坛的某个时候提到过它,天文摄影。这个爱好具有挑战性和回报性,真的让我在休假期间很忙。是的,有时设备感觉它需要满足大锤,但这就是它保持吸引力的原因。在接下来的2年里,从望远镜和相机开始的小虫子,已经演变成一些相当不错的新设备收购,即,我打破了信用卡购买望远镜、天文冷却相机、新支架,甚至建造了一个花园里的天文台。

随着新设备数量的增加,我从12个完整的成像设备,我一直在运行,并且有足够的设备可以运行第三个,但是在一个晚上运行两个设备就足够了暂且。

就在这篇文章出现的时候,我有一个问题要解决,所以我求助于软件......我自己的。

背景

我购买的其中一件设备是UniHendron天空质量计,这是一种监控天空亮度的设备,可让您确定天空照明何时发生变化,以及何时达到您所在地区的正常黑暗开始成像会话。您还可以看到月球对天空亮度和一般光污染的影响。

我购买的型号是USB版本的,它连接到成像计算机/笔记本电脑,APTNINA等天文摄影软件可以读取这些数据并将天空质量读取嵌入到图像文件的FITS头数据中,可以后来查看以检查为什么特定图像或一系列图像具有不同的合同等。可能云已经滚入,或者邻居打开了安全灯等。

随着时间的推移,我获得了第二个望远镜/成像装置,我不想去为第二个装备购买另一个USB仪表,或者不得不用以太网版本替换设备。

我试着用我在网上找到的一个叫做SerialToIP的小应用程序来克服这个问题。 然而,这被证明是不可靠的并且容易出错。

设备制造商发布了设备使用的串行协议,所以我想我会尝试编写自己的应用程序,该应用程序可以通过TCP服务来自客户端的请求并从设备读取数据,返回必要的数据给客户。

ASCOM是一个为提供天文学通用标准而开发的平台,以允许来自不同制造商的不同类型的天文学设备之间的互操作,以便所有人一起交谈/工作。DizzyAstronomySQM设备编写了一个ASCOM驱动程序,允许APTNINA之类的设备通过TCP读取SQM数据,并且可以将ASCOM驱动程序配置为直接从设备的USB或以太网版本读取。本文介绍的应用程序位于ASCOM驱动程序和设备之间,使其显示为以太网版本,因此允许多个客户端与USB设备一起工作。

USB设备连接到成像设备1的主机,然后将每个成像设备上的天文摄影软件指向设备1主机PC的配置IP/端口。

那么它看起来像什么?

在我们进入应用程序细节之前,它是什么样的?好吧,这是应用程序的正面界面;

应用程序的前屏幕提供用于访问各种配置选项的菜单,顶部的两个面板提供连接/断开USB设备以及启动/停止服务器端以服务客户端的能力。

这两个部分还显示了从设备或客户端收到的最后一个原始消息。

该趋势提供了数据点的历史记录,让您可以直观地看到黑暗如何变化、温度如何变化以及计算出的裸眼极限幅度如何变化。

沿着状态条,它显示连接和服务器状态,以及何时进行轮询或服务客户端以及内存池中有多少数据记录。

配置屏幕是典型的配置类型元素,用于设置com端口、IP/端口、趋势颜色、日志目录和要保留在内存中的数据点数量。

应用程序结构

该应用程序的基本框图如下所示:

我使用了一个事件系统来与核心元素进行通信和传递数据,这些核心元素被分解成单独的类,如果需要,这些类在它们自己的线程中运行。

SerialManager处理与USB设备的通信,处理NetworkManager客户端通信。

SettingsManager用于协调应用程序的所有设置并持久化到磁盘。

DataStore侦听数据事件消息以将记录存储在内存中,如果已配置,则存储到磁盘。

Trend是一个运行在主窗体之上的用户控件。

串行管理器

配置并连接串行端口后, SerialManager轮询将指定内部的USB设备。轮询是使用简单的Timer。该协议使用三个不同的命令'ix''rx''ux'

进行民意调查的方法是SendCommand

ix命令返回有关设备的信息,例如协议版本、序列号。

rx命令返回与天空质量的当前读数、温度和内部用于计算天空质量值的频率读数相关的数据。

ux命令返回与数据点的非平均读数相关的数据。

public static bool SendCommand(string command )
        {
            if (instance == null) return false;

            string[] validCommands = new string[] { "ix", "rx", "ux" };

            if (!validCommands.Contains(command))
            {
                System.Diagnostics.Debug.WriteLine($"Invalid Command: '{command}'");
                return false;
            }

            retry_send_message:

            //Unit Information
            if (_serialPort.IsOpen)
            {
                System.Diagnostics.Debug.WriteLine($"Sending command '{command}'");
                instance.TriggerSerialPollBegin();
                _serialPort.WriteLine(command);
                instance._noResponseTimer.Start();
                
                return true;
            }
            else
            {
                //Port is closed attempt to reconnect
                System.Diagnostics.Debug.WriteLine("Port Closed. no command sent");
                int retryCounter=0;

                while (!_serialPort.IsOpen)
                {
                    retryCounter += 1;

                    if (retryCounter > 20)
                    { break; }

                    System.Diagnostics.Debug.WriteLine
                    ($"Attempting to reconnect serial. Attempt: {retryCounter}");

                    instance._connectionState = SerialConnectedStates.Retry; // "retry";
                    instance.TriggerSerialStateChangeEvent();

                    try {
                        //Need to wait for the port to reappear / be plugged back in
                        
                        if (System.IO.Ports.SerialPort.GetPortNames().Contains
                           (_serialPortName))
                        {
                            _serialPort.Open();
                        }
                        else
                        {
                            System.Diagnostics.Debug.WriteLine
                            ("Waiting for port to re-appear/connected");
                        }
                    }
                    catch ( Exception ex)
                    {
                        System.Diagnostics.Debug.WriteLine(ex.ToString());
                    }

                    if (_serialPort.IsOpen)
                    {
                        instance._connectionState = 
                        SerialConnectedStates.Connected; // "connected";
                        instance.TriggerSerialStateChangeEvent();

                        goto retry_send_message;
                    }
                    Thread.Sleep(1000);
                }

                System.Diagnostics.Debug.WriteLine
                ($"Reconnect serial failed. {retryCounter}");

                //Exceeded retry counter
                return false;
            }
        }

在上面的代码中,您将看到我们在发送命令时设置了一个标志,并且计时器开始检查我们是否在给定时间内收到了有效的响应。例如,com端口已成功打开,但正在接收接收方无法识别的任何已发送数据。

解析并检查串行响应以查看返回了哪个消息,然后将此数据打包到EventArgs中并引发一个事件,以允许DataStore将每个命令响应的一条记录放入最新的快照存储中。

典型的响应如下所示:

r, 06.70m,0000022921Hz,0000000020c,0000000.000s, 039.4C

在本例中,第一个字母是与r命令相关的命令响应rx。解析响应以仅提取我们需要的数据,在此响应中,它将是0.67039.4C.

  • 0.67是以幅度/弧秒平方为单位的天空质量读数。
  • 039.4C是以Centrigrade为单位的单位温度。

命令响应也以'\r\n'转义序列终止。

数据的接收由连接打开时附加SerialPort的事件处理程序处理 

private static void SerialDataReceivedHandler
(object sender, System.IO.Ports.SerialDataReceivedEventArgs e)
        {
            if (instance == null) return;

            string data = _serialPort.ReadLine();

            //Update the latest SQM Readings Store

            if (data.StartsWith("i,"))
            {
                //ix command
                instance._noResponseTimer.Stop();
                SQMLatestMessageReading.SetReading("ix", data);
            }
            else if (data.StartsWith("r,"))
            {
                //rx command
                instance._noResponseTimer.Stop();
                SQMLatestMessageReading.SetReading("rx", data);
            }
            else if (data.StartsWith("u,"))
            {
                //ux command
                instance._noResponseTimer.Stop();
                SQMLatestMessageReading.SetReading("ux", data);

            }
            instance.TriggerSerialDataReceivedEvent(data);
            instance.TriggerSerialPollEnd();
        }

网络管理器

NetworkManager在配置的端口上建立一个监听器并等待客户端请求。

private void StartNetworkServer()
        {
            if (instance == null) return;

            IPEndPoint localEndPoint = new(IPAddress.Any, _serverPort);

            System.Diagnostics.Debug.WriteLine("Opening listener...");
            _serverState = Enums.ServerRunningStates.Running; 
            TriggerStateChangeEvent();

            listener = new Socket(localEndPoint.Address.AddressFamily, 
                                  SocketType.Stream, ProtocolType.Tcp);

            try
            {
                listener.Bind(localEndPoint);
                listener.Listen();
                instance.token.Register(CancelServer);

                instance.token.ThrowIfCancellationRequested();

                while (!instance.token.IsCancellationRequested) 
                {
                    allDone.Reset();
                    System.Diagnostics.Debug.WriteLine("Waiting for connection...");
                    listener.BeginAccept(new AsyncCallback(AcceptCallback), listener);

                    allDone.WaitOne();
                }
            }
            catch (Exception e)
            {
                System.Diagnostics.Debug.WriteLine(e.ToString());
            }
            finally
            {
                listener.Close();
            }

            System.Diagnostics.Debug.WriteLine("Closing listener...");
            _serverState = Enums.ServerRunningStates.Stopped; 
        }

当客户端连接时,会处理回调,检查客户端请求是否有相关的命令请求(ixrxux),并使用来自快照存储的最新数据进行响应。

private static void AcceptCallback(IAsyncResult ar)
        {
            if (instance == null) return;
            try
            {
                //Client connected
                _clientCount++;
                instance.TriggerStateChangeEvent();

                // Get the socket that handles the client request.  
                if (ar.AsyncState == null) return;

                Socket listener = (Socket)ar.AsyncState;

                Socket handler = listener.EndAccept(ar);

                // Signal the main thread to continue.  
                instance.allDone.Set();

                // Create the state object.  
                StateObject state = new();
                state.WorkSocket = handler;

                while (_serverState == Enums.ServerRunningStates.Running)
                {
                    if (state.WorkSocket.Available > 0)
                    {
                        handler.Receive(state.buffer);
                        state.sb.Clear();   //clear the stringbuilder
                        state.sb.Append
                        (Encoding.UTF8.GetString(state.buffer)); //move the buffer 
                                              //into the string builder for processing
                        state.buffer = new byte[StateObject.BufferSize];//clear the 
                                              //buffer ready for the next 
                                              //incoming message
                    }
                    if (state.sb.Length > 0)
                    {
                        int byteCountSent = 0;
                        string? latestMessage;
                        //Temporary Test Message Handlers
                        if (state.sb.ToString().StartsWith("ix")) //Encoding.UTF8.
                                                           //GetString(state.buffer)
                        {
                            instance._serverLastMessage = 
                                     $"{state.WorkSocket.RemoteEndPoint}: ix";
                            instance.TriggerServerMessageReceived();

                            SQMLatestMessageReading.GetReading("ix", out latestMessage);
                            latestMessage += "\n"; //Add a line feed as this 
                                                   //is getting stripped out somewhere!

                            byteCountSent = handler.Send(Encoding.UTF8.GetBytes
                            (latestMessage), latestMessage.Length, SocketFlags.None);
                        }
                        if (state.sb.ToString().StartsWith("rx")) //
                        {
                            instance._serverLastMessage = 
                                     $"{state.WorkSocket.RemoteEndPoint}: rx";
                            instance.TriggerServerMessageReceived();

                            SQMLatestMessageReading.GetReading("rx", out latestMessage);
                            latestMessage += "\n";

                            byteCountSent = handler.Send
                            (Encoding.UTF8.GetBytes(latestMessage), 
                             latestMessage.Length, SocketFlags.None);
                        }
                        if (state.sb.ToString().StartsWith("ux")) //
                        {

                            instance._serverLastMessage = 
                                     $"{state.WorkSocket.RemoteEndPoint}: ux";
                            instance.TriggerServerMessageReceived();

                            SQMLatestMessageReading.GetReading("ux", out latestMessage);
                            latestMessage += "\n";

                            byteCountSent = handler.Send(Encoding.UTF8.GetBytes
                            (latestMessage), latestMessage.Length, SocketFlags.None);
                        }

                        Thread.Sleep(250);
                    }
                }
                //handler.BeginReceive(state.buffer, 0, StateObject.BufferSize, 0,
                //  new AsyncCallback(ReadCallback), state);
            }
            catch (SocketException ex)
            {

                System.Diagnostics.Debug.WriteLine
                       ($"Socket Error: {ex.ErrorCode}, {ex.Message}");

                //Info: https://docs.microsoft.com/en-us/dotnet/api/
                //system.net.sockets.socketerror?view=net-6.0
                //10053 = The connection was aborted by .NET or the 
                //underlying socket provider.
                //
                if (instance != null)
                {
                    _clientCount--;
                    if (_clientCount < 0)
                    {
                        _clientCount = 0;
                    }

                    instance.TriggerStateChangeEvent();
                }
            }
            catch (ObjectDisposedException ex)
            {
                System.Diagnostics.Debug.WriteLine(ex.ToString());
                //catch and dump this when form is closing
            }
            catch (Exception ex)
            {
                System.Diagnostics.Debug.WriteLine(ex.ToString());
                throw;
                //left this for future unhandled conditions 
                //to see what might need change.
            }
        }

数据存储

DataStore是一个简单的DataPoints列表。它侦听串行数据事件并将最新数据附加到存储区。还有一个记录计时器,它将在记录间隔内将最新数据写入磁盘。

private void LoggingTimer_Tick(object? sender, EventArgs e)
        {
            if (!serialConnected)
            {
                //exit if not connected
                return;
            }

            //Build the datapoint
            //Check there is both an rx and ux in the snapshot
            bool hasData = false;
            DataStoreDataPoint datapoint = new();

            if (SQMLatestMessageReading.HasReadingForCommand("ux") && 
                SQMLatestMessageReading.HasReadingForCommand("rx"))
            {
                //string rxMessage;
                SQMLatestMessageReading.GetReading("rx", out string? rxMessage);
                //string uxMessage;
                SQMLatestMessageReading.GetReading("ux", out string? uxMessage);

                if (rxMessage != null && uxMessage != null)
                {
                    string[] dataRx = rxMessage.Split(',');
                    string[] dataUx = uxMessage.Split(',');
                    datapoint.Timestamp = DateTime.Now;
                    datapoint.AvgMPAS = Utility.ConvertDataToDouble(dataRx[1]);
                    datapoint.RawMPAS = Utility.ConvertDataToDouble(dataUx[1]);
                    datapoint.AvgTemp = Utility.ConvertDataToDouble(dataRx[5]);
                    datapoint.NELM = Utility.CalcNELM
                              (Utility.ConvertDataToDouble(dataRx[1]));

                    hasData = true;
                }
                else { hasData = false; }
            }

            //Memory Logging
            if (hasData)
            {
                AddDataPoint(datapoint);
            }

            //File Logging
            if (_fileLoggingEnabled && hasData)
            {
                string fullpathfile;
                if (_fileLoggingUseDefaultPath)
                {
                    fullpathfile = Path.Combine
                    (Application.StartupPath, "logs", filename);
                }
                else
                {
                    fullpathfile = Path.Combine(_fileLoggingCustomPath, filename);
                }
                try
                {
                    File.AppendAllText(fullpathfile,
                    $"{datapoint.Timestamp:yyyy-MM-dd-HH:mm:ss}, 
                    {datapoint.RawMPAS:#0.00} raw-mpas, 
                    {datapoint.AvgMPAS:#0.00} avg-mpas, 
                    {datapoint.AvgTemp:#0.0} C, {datapoint.NELM:#0.00} NELM\n");
                }
                catch (Exception ex)
                {
                    DialogResult result = MessageBox.Show
                    ($"Error writing log - {ex.Message} \n Retry or Cancel 
                    to halt file logging.", "File Logging Error", 
                    MessageBoxButtons.RetryCancel, MessageBoxIcon.Error);
                    if (result == DialogResult.Cancel)
                    {
                        _fileLoggingEnabled = false;
                    }
                }   
            }
        }

private static void AddDataPoint(DataStoreDataPoint datapoint)
        {
            if (data == null || store == null) return;
            
            while (!_memoryLoggingNoLimit && data.Count >= 
                    _memoryLoggingRecordLimit && data.Count > 0)
            {
                //remove 1st point
                data.RemoveAt(0);
            } //keep removing first point until space available

            //Add record
            data.Add(datapoint);
            store.TriggeDataStoreRecordAdded();
        }

趋势

趋势用户控件由PictureBox构成。趋势本身有一个picturebox,趋势右侧的趋势标记有另一个picturebox

趋势由在后台生成的位图图层组成,基础图层包含趋势的网格线。其位置被计算、迭代并绘制到基础层上。

还有四个其他数据层,每个数据层用于趋势数据参数。这种方法可以轻松打开和关闭图层。数据层具有透明的基色,然后将数据点绘制到层上。绘制每条记录时,将绘制一个1像素宽的新记录段,然后将其附加到该参数的主数据层。

绘制完所有四个数据层后,将四个层合成在基础层之上,加载到图片框中并显示给用户。

private void UpdatePoints()
        {
            updateInProgress = true;

            while (localBuffer.Count > 0)
            {
                if (backgroundTrendRecord == null)
                {
                    backgroundTrendRecord = new(1, pictureBoxTrend.Height);

                    //Trend Starting, so show the labels
                    labelMax.Visible = true;
                    labelValue34.Visible = true;
                    labelMid.Visible = true;
                    labelValue14.Visible = true;
                    labelMin.Visible = true;
                }

                //set the background color
                for (int i = 0; i < backgroundTrendRecord.Height; i++)
                {
                    backgroundTrendRecord.SetPixel(0, i, pictureBoxTrend.BackColor);
                }

                //Draw background trend lines
                //int mainInterval = backgroundTrendRecord.Height / 4;
                //int subInterval = backgroundTrendRecord.Height / 40;
                double subInterval = backgroundTrendRecord.Height / 20.0;

                //increment the horizontal dash counter
                drawHorizontalBlankSubdivisionCounter++;
                if (drawHorizontalBlankSubdivisionCounter > 9)
                {
                    drawHorizontalBlankSubdivision = !drawHorizontalBlankSubdivision;
                    drawHorizontalBlankSubdivisionCounter = 0;
                }

                for (double position = 0; 
                position < backgroundTrendRecord.Height; position += subInterval)
                {
                    if (position < backgroundTrendRecord.Height)
                    {
                        backgroundTrendRecord.SetPixel(0, Convert.ToInt32(position), 
                        Color.FromKnownColor(KnownColor.LightGray));
                    }
                }

                //Main

                //increment the 2 vertical position counters.
                drawVerticalSubDivisionCounter++;
                drawVerticalMainDivisionCounter++;

                if (drawVerticalSubDivisionCounter > 9)
                {
                    for (int outer = 0; outer < backgroundTrendRecord.Height; outer++)
                    {
                        backgroundTrendRecord.SetPixel
                        (0, outer, Color.FromKnownColor(KnownColor.LightGray));
                    }
                    drawVerticalSubDivisionCounter = 0;
                }

                if (drawVerticalMainDivisionCounter > 49)
                {
                    for (int i = 0; i < backgroundTrendRecord.Height; i++)
                    {
                        backgroundTrendRecord.SetPixel
                        (0, i, Color.FromKnownColor(KnownColor.Black));
                    }
                    drawVerticalMainDivisionCounter = 0;
                }

                //Main Division Horizontal lines
                backgroundTrendRecord.SetPixel
                          (0, 0, Color.FromKnownColor(KnownColor.Black));
                backgroundTrendRecord.SetPixel
                          (0, Convert.ToInt32(subInterval * 5), 
                           Color.FromKnownColor(KnownColor.Black));
                backgroundTrendRecord.SetPixel
                          (0, Convert.ToInt32(subInterval * 10), 
                           Color.FromKnownColor(KnownColor.Black));
                backgroundTrendRecord.SetPixel
                          (0, Convert.ToInt32(subInterval * 15), 
                           Color.FromKnownColor(KnownColor.Black));
                backgroundTrendRecord.SetPixel
                          (0, backgroundTrendRecord.Height - 1, 
                           Color.FromKnownColor(KnownColor.Black));

                //Join the trend record to the master background trend, 
                //check its size if needed
                if (backgroundMasterTrend == null)
                {
                    backgroundMasterTrend = new(1, pictureBoxTrend.Height);
                }
                else
                {
                    //Check if there is a logging limit and 
                    //crop the mastertrend accordingly
                    if (!SettingsManager.MemoryLoggingNoLimit && 
                    backgroundMasterTrend.Width > SettingsManager.MemoryLoggingRecordLimit)
                    {
                        Bitmap newBitmap = new(SettingsManager.MemoryLoggingRecordLimit, 
                                               backgroundMasterTrend.Height);
                        using (Graphics gNew = Graphics.FromImage(newBitmap))
                        {
                            Rectangle cloneRect = new(backgroundMasterTrend.Width - 
                            SettingsManager.MemoryLoggingRecordLimit, 0, 
                            SettingsManager.MemoryLoggingRecordLimit, 
                            backgroundMasterTrend.Height);
                            Bitmap clone = backgroundMasterTrend.Clone
                            (cloneRect, backgroundMasterTrend.PixelFormat);

                            gNew.DrawImage(clone, 0, 0);
                        }
                        backgroundMasterTrend = newBitmap;
                    }
                }

                Bitmap bitmap = new(backgroundMasterTrend.Width + 
                                backgroundTrendRecord.Width, 
                                Math.Max(backgroundMasterTrend.Height, 
                                backgroundTrendRecord.Height));
                using Graphics g = Graphics.FromImage(bitmap);
                {
                    g.DrawImage(backgroundMasterTrend, 0, 0);
                    g.DrawImage(backgroundTrendRecord, backgroundMasterTrend.Width, 0);
                }
                backgroundMasterTrend = bitmap;

                //Draw DataPoints
                DataStoreDataPoint data = localBuffer.First();

                int y;

                int penRaw = 0;     //Store the y for each point as used 
                                    //for creating the vertical point connection lines
                int penAvg = 0;
                int penTemp = 0;
                int penNELM = 0;

                // Temperature
                
                if (layerTempRecord == null)
                {
                    layerTempRecord = new(1, pictureBoxTrend.Height);
                }

                //set the background color
                for (int i = 0; i < layerTempRecord.Height; i++)
                {
                    layerTempRecord.SetPixel(0, i, Color.Transparent);
                }

                if (SettingsManager.TemperatureUnits == 
                                    Enums.TemperatureUnits.Centrigrade)
                {
                    y = layerTempRecord.Height - (Convert.ToInt32(data.AvgTemp / 
                        (trendMaximum - trendMinimum) * layerTempRecord.Height));
                }
                else
                {
                    y = layerTempRecord.Height - 
                        (Convert.ToInt32(Utility.ConvertTempCtoF(data.AvgTemp) / 
                        (trendMaximum - trendMinimum) * layerTempRecord.Height));
                }
                if (y < 0) { y = 0; } else if (y >= pictureBoxTrend.Height) 
                                      { y = pictureBoxTrend.Height - 1; }
                layerTempRecord.SetPixel(0, y, checkBoxTemp.ForeColor);

                penTemp = y;

                //Vertical Joins
                if (firstPointsDrawn && y != lastPointTemp)
                {
                    if (lastPointTemp > y)
                    {
                        for (int pos = lastPointTemp; pos > y; pos--)
                        {
                            layerTempRecord.SetPixel(0, pos, checkBoxTemp.ForeColor);
                        }
                    }
                    else
                    {
                        for (int pos = lastPointTemp; pos < y; pos++)
                        {
                            layerTempRecord.SetPixel(0, pos, checkBoxTemp.ForeColor);
                        }
                    }
                }

                lastPointTemp = y; //Store the last position, 
                                   //will be used to draw the markers later.

                //Join the trend record to the layer trend, check its size if needed
                if (layerTemp == null)
                {
                    layerTemp = new(1, pictureBoxTrend.Height);
                }
                else
                {
                    //Check if there is a logging limit and crop the layer accordingly
                    if (!SettingsManager.MemoryLoggingNoLimit && 
                        layerTemp.Width > SettingsManager.MemoryLoggingRecordLimit)
                    {
                        Bitmap newTempBitmap = 
                        new(SettingsManager.MemoryLoggingRecordLimit, layerTemp.Height);
                        using Graphics gTempClone = Graphics.FromImage(newTempBitmap);
                        {
                            Rectangle cloneRect = new(layerTemp.Width - 
                            SettingsManager.MemoryLoggingRecordLimit, 0, 
                            SettingsManager.MemoryLoggingRecordLimit, layerTemp.Height);
                            Bitmap clone = layerTemp.Clone(cloneRect, 
                                           layerTemp.PixelFormat);

                            gTempClone.DrawImage(clone, 0, 0);
                        }
                        layerTemp = newTempBitmap;
                    }
                }

                Bitmap bitmapTemp = new(layerTemp.Width + layerTempRecord.Width, 
                                    Math.Max(layerTemp.Height, layerTempRecord.Height));
                using Graphics gTemp = Graphics.FromImage(bitmapTemp);
                {
                    gTemp.DrawImage(layerTemp, 0, 0);
                    gTemp.DrawImage(layerTempRecord, layerTemp.Width, 0);
                }
                layerTemp = bitmapTemp;

上面的代码只显示了背景基础层和温度参数,其他三个参数以与温度相同的方法重复。

需要注意的一点是,我们记住了为该参数绘制的最后一个数据点的位置,这允许随后添加垂直线以连接相邻点。

图层也会被裁剪,以确保它不超过设置中的最大数据点记录数。

以下两种方法生成完成的趋势并显示它。

private void GenerateCompletedTrend()
        {
            //Generate the composite trend for active layers

            if (backgroundMasterTrend == null)
            {
                return;
            }

            Bitmap bitmapFinal = new(backgroundMasterTrend.Width, 
                                     backgroundMasterTrend.Height);
            using Graphics gFinal = Graphics.FromImage(bitmapFinal);
            {
                //Draw the background trend lines
                gFinal.DrawImage(backgroundMasterTrend, 0, 0);

                //Draw the data layers - Draw the least priority first for overlap
                if (checkBoxTemp.Checked && layerTemp != null)
                {
                    gFinal.DrawImage(layerTemp, 0, 0);
                }
                if (checkBoxNELM.Checked && layerNELM != null)
                {
                    gFinal.DrawImage(layerNELM, 0, 0);
                }
                if (checkBoxRawMPAS.Checked && layerRawMPAS != null)
                {
                    gFinal.DrawImage(layerRawMPAS, 0, 0);
                }
                if (checkBoxAvgMPAS.Checked && layerAvgMPAS != null)
                {
                    gFinal.DrawImage(layerAvgMPAS, 0, 0);
                }
            }
            completeTrend = bitmapFinal;
        }

        private void DisplayTrend()
        {
            if (completeTrend == null)
            {
                return;
            }
            
            pictureBoxTrend.Width = completeTrend.Width;
            pictureBoxTrend.Image = completeTrend;

            if (pictureBoxTrend.Width > (this.Width - pictureBoxPens.Width))
            {
                buttonRunStop.Visible = true;
                horizontalTrendScroll.Maximum = pictureBoxTrend.Width - this.Width + 
                pictureBoxPens.Width;  //increase the maximum on the scroll bar
                if (autoScroll)
                {
                    horizontalTrendScroll.Value = horizontalTrendScroll.Maximum;
                    pictureBoxTrend.Left = pictureBoxPens.Left - 
                    pictureBoxTrend.Width;     //shift the picturebox left, 
                                               //so latest data is visible
                }
                else
                {
                    pictureBoxTrend.Left = horizontalTrendScroll.Value * -1;
                }
            }
            else
            {
                buttonRunStop.Visible = false;
                horizontalTrendScroll.Enabled = false;
                horizontalTrendScroll.Maximum = 0;
                pictureBoxTrend.Left = this.Width - pictureBoxTrend.Width - 
                                       pictureBoxPens.Width;
            }
            if (autoScroll)
            {
                buttonRunStop.Text = "\u23F8"; //Pause
            }
            else
            {
                buttonRunStop.Text = "\u23F5"; //Run
            }

            pictureBoxTrend.Refresh();
        }

当趋势位图超出显示图片框的可见宽度时,我们偏移图片并显示滚动条,这允许用户向后滚动,在屏幕上来回移动趋势。

我们使用填充多边形在趋势右侧绘制标记。

private void DrawMarkers(int penRaw, int penAvg, int penTemp, int penNELM)
{
    //Draw the Pen Markers
    Graphics gPen = pictureBoxPens.CreateGraphics();
    {
        gPen.Clear(pictureBoxTrend.BackColor);
        //Draw them in this order for overlap priority
        if (checkBoxTemp.Checked)
        {
            Point[] points = { new Point(0, penTemp),
            new Point(15, penTemp - 4), new Point(15, penTemp + 4) };
            gPen.FillPolygon(new SolidBrush(checkBoxTemp.ForeColor), points);
        }
        if (checkBoxNELM.Checked)
        {
            Point[] points = { new Point(0, penNELM),
            new Point(15, penNELM - 4), new Point(15, penNELM + 4) };
            gPen.FillPolygon(new SolidBrush(checkBoxNELM.ForeColor), points);
        }
        if (checkBoxRawMPAS.Checked)
        {
            Point[] points = { new Point(0, penRaw),
            new Point(15, penRaw - 4), new Point(15, penRaw + 4) };
            gPen.FillPolygon(new SolidBrush(checkBoxRawMPAS.ForeColor), points);
        }
        if (checkBoxAvgMPAS.Checked)
        {
            Point[] points = { new Point(0, penAvg),
            new Point(15, penAvg - 4), new Point(15, penAvg + 4) };
            gPen.FillPolygon(new SolidBrush(checkBoxAvgMPAS.ForeColor), points);
        }
    }
}

视频介绍

我还制作了该应用程序的视频介绍,可以在我的Youtube天文摄影频道上找到:

兴趣点

你有它......服务于其预期目标的应用程序的摘要。完整的代码库在Github上维护和发布,因为我将应用程序提供给天文学社区使用。

该存储库可以在GitHub - Daves-Astrophotography/UniUSBSQMServer: Unihedron USB SQM TCP Server找到。

参考

https://www.codeproject.com/Articles/5333216/Sharing-a-USB-Serial-Device-to-Multiple-Clients-ov

使用方法 -------- 1. 点击ComfoolerySetup.exe安装Comfoolery 2. 配置Comfoolery,详见“菜单说明”一节 3. 通告串口服务器IP、端口号 4. 客户端telnet连接串口服务器 菜单说明 -------- * File,仅含退出选项,一般用不到 * Edit,含Com Settings和TCP Settings两个选项 ** Com Settings,配置要共享的串口信息 *** Com Port #,待共享的串口号 *** Baud Rate,波特率 *** Parity,一般选择“None” *** Data bits,一般选择“8” *** Stop bits,一般选择“1” *** Flow Control,一般选择“None” ** TCP Settings,配置共享服务器端口 *** Read-only port number,当客户端连接此端口号时,只能读串口输出的信息,不能对串口进行写操作 *** Read/write port number,当客户端连接此端口号时,不但能读串口输出的信息,还可对串口进行写操作 * Help,一般用不到 客户端连接说明 -------------- 使用telnet工具,按服务器的IP加共享的端口号即可连接。 注意使用时,需要为telnet工具配置“Force character at a time mode”,否则telnet工具敲回车会多回显一次本次输入,使用效果不佳。 * SecureCRT,右击标签,选择“Session Options”,点击左侧“Category”->“Connection”->"Telnet",在右侧勾选“Force character at a time mode”,保存退出。 * Linux命令行,"telnet 服务器IP 端口号",敲ctrl + ],执行mode character,就可以进入单字符模式("character at a time" mode)。 其他说明 -------- 打开多个Comfoolery实例可实现多串口共享
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值