Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Languages / C#

Sharing a USB Serial Device to Multiple Clients over TCP

5.00/5 (12 votes)
23 May 2022BSD9 min read 11.5K  
Putting together an application to share a USB Sky Quality Meter to Multiple Clients
I owned a USB attached Sky Quality Meter, and needed to share the device with multiple astrophotography applications without the need to replace it with an ethernet version of the same device, or purchase multiple USB devices for each imaging rig.

Prelude

After 10 years of not touching any C# (since the UltraDynamo application in the Intel App competition in 2012!), I dusted off and upgraded Visual Studio to the latest version and set off to try and solve a problem I had. So, the code is probably not pretty, full of bad structure, etc. etc. but it works, and that's all that matters to me at the moment - it solved my problem!

Introduction

Back in 2020, I started off on a new hobby, I did mention it at some point in the forums, Astrophotography. The hobby is challenging and rewarding and really keeps me occupied during my time off. Yes, sometimes the equipment feels like it needs to meet the sledgehammer, but that is what keeps it engaging. Over the following 2 years, the bug bit and what started off as a telescope and camera, has evolved to some pretty nice acquisitions of new equipment, i.e., I broke the credit card buying telescope, astro cooled cameras, new mounts and even built an observatory in the garden.

As the amount of new equipment grew, I went from 1 to 2 fully complete imaging rigs that I run all the time, and have enough equipment that I could potentially run a 3rd, but running two rigs on an evening is enough of a handful for the time being.

And that is when this article came to be, I had a problem to solve so I turned to software..... my own.

Background

One of the pieces of equipment that I had purchased was a UniHendron Sky Quality Meter, this is a device that monitors the sky brightness and allows you to determine when the sky illumination changes, and also when you reach the 'normal' darkness for your area to start imaging sessions. You can also see what impact the Moon is having in the sky brightness and also general light pollution.

The model I purchased was a USB version, and this connects to the imaging computer/laptop and the astrophotography software such as APT or N.I.N.A. can read this data and embedded the sky quality reading into the FITS header data for the image file, which can be later viewed to check why a particular image or series of images had different contracts, etc. maybe clouds have rolled in, or the neighbour has switched on a security light, etc.

Over time, I then acquired a second telescope/imaging setup and I didn't want to go and purchase another USB meter for this second rig, or have to replace the device with the Ethernet version.

I tried to overcome this using a small application I found on the internet called SerialToIP. However, this proved to be unreliable and prone to errors.

The manufacturers of the device publish the serial protocol that is used by the device, so I thought I would have a go and try to write my own application that can serve requests from clients over TCP and read the data from the device, returning the necessary data to the clients.

ASCOM is a platform that was developed to provide "Universal Standards For Astronomy" to allow interop between different types of astronomy equipment from different manufactures to all talk/work together. DizzyAstronomy had written an ASCOM driver for the SQM devices that allowed the likes of APT and N.I.N.A. to read the SQM data over TCP, and the ASCOM driver can be configured to read directly from either the USB or Ethernet versions of the device. The application covered in this article sits between the ASCOM driver and the Device to make it appear as an Ethernet version and subsequently therefore allow multiple clients to function with the USB device.

The USB device is connected to the host computer of imaging rig 1, and then the astrophotography software on each imaging rig is pointed towards the configured IP/Port of the host PC of Rig 1.

So What Does It Look Like?

Before we get into the application details, what does it look like? Well, here is the front screen of the application;

Image 1

The front screen of the application provides menus for accessing the various configuration options and at the top two panels provides the ability to connect/disconnect to the USB device and also start/stop the server side for serving the clients.

Both these sections also show the last raw messages received from the device or from the client.

The trend provides the history of the data points to allow you to visually see how the darkness is changing, the temperature is changing and the calculated 'Naked Eye Limiting Magnitude' is changing.

Along the status strip, it shows the connection and server status, as well as when a poll is being made or a client is being served and how many data records are in the memory pool.

The config screens are your typical configuration type elements, for setting com ports, ip/ports, trend colours, logging directories and number of data points to retain in memory.

Application Structure

The basic block diagram for the application is shown below:

Image 2

I have used an eventing system to communicate and pass data around with the core elements broken out into separate classes that run in their own threads if necessary.

The SerialManager deals with the communications to the USB device, the NetworkManager deals with the client communications.

A SettingsManager is used to coordinate all the settings for the application and persistent to disk.

The DataStore listens for the data event messages for storing records in memory and if configured, to disk.

The Trend is a usercontrol running on top of the main form.

SerialManager

Once the serial port has been configured and connected, the SerialManager polls the USB device at the specified internal. Polling is done using a simple Timer. The protocol uses three different commands 'ix', 'rx', 'ux'.

The method for doing the poll is the SendCommand.

The ix command returns information about the device, such as protocol version, serial number.

The rx command returns data relating to the current reading of sky quality, temperature and the frequency reading used internally for the calculation of the sky quality value.

The ux command returns data relating to the unaveraged readings for the data points.

C#
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;
            }
        }

In the code above, you will see that we set a flag when a command is sent, and timer started to check that we receive a valid response in a given time. This is, for instance, where a com port has opened successfully, but is sinking any data sent, that is not recognised by the receiver.

The serial response is parsed and checked to see which message has been returned, this data is then packaged into an EventArgs and an event raised, to allow the DataStore to put into the latest snapshot store, one record for each of the command responses.

A typical response looks like below:

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

In this example, the first letter is the command response 'r' which related to the 'rx' command. The response is parsed to pull out only the data we need, in this response, it would be 0.67 and the 039.4C.

  • 0.67 is the sky quality reading in magnitude/arc second squared.
  • 039.4C is the temperature of the unit in Centrigrade.

The command responses are also terminated with a '\r\n' escape sequence.

The receipt of data is handled by an Event Handler that is attached to the SerialPort when the connection is opened.

C#
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

The NetworkManager established a listener on the configured port and waits for client requests.

C#
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; 
        }

When the client connects, a callback is handled, the client request is checked for the relevant command request (ix, rx or ux), and responds with the latest data from the snapshot store.

C#
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

The DataStore is a simple list of DataPoints. It listens for the Serial Data events and appends the latest data to the store. There is also a logging timer, this will write to disk at the logging interval the latest data.

C#
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();
        }

Trending

The trend user control is built up of PictureBoxes. There is one picturebox for the trend itself and another for the trend markers at the right of the trend.

The trend is built up of bitmap layers which are generated in the background, the base layer contains the gridlines for the trend. The position of which is calculated, iterated and drawn onto the base layer.

There are four other data layers, one for each of the trended data parameters. This approach allows layers to be easily turned on and off. The data layers have a base colour of transparent, and then the data points drawn onto the layer. When each record is drawn, a new record segment of 1 pixel wide is drawn, this is then appended to the master data layer for that parameter.

Once all the four data layers have been drawn, the four layers are composited on top of the base layer, loaded into the picture box and displayed to the user.

C#
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;

The code above only shows the background base layer and the temperature parameter, the other three parameters are repeated the same approach as the temperature.

One thing to note is we remember the position of the last data point drawn for that parameter, this allows then to also add vertical lines to join between the adjacent points.

The layers are also cropped to ensure it does not exceed the maximum data point record count from the settings.

The following two methods generate the completed trend and display it.

C#
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();
        }

When the trends bitmap exceeds the visible width of the displayed picture box, we offset the picture and also display a scroll bar, this allows the user to scroll backwards, moving the trend back and forth on screen.

We use a filled polygon to draw the markers on the right of the trend.

C#
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);
        }
    }
}

Video Introduction

I also created a video introduction to the application that can be found on my astrophotography channel on Youtube at:

Image 3

Points of Interest

There you have it... a summary of the application that served its desired goal. The full code base is maintained and published on Github, as I made the application available for use by the astronomy community.

The repository can be found at https://github.com/Daves-Astrophotography/UniUSBSQMServer.

Image 4

References

History

  • V1.0 - 23rd May 2022 - First article release

License

This article, along with any associated source code and files, is licensed under The BSD License