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;
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:
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.
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:
if (_serialPort.IsOpen)
{
System.Diagnostics.Debug.WriteLine($"Sending command '{command}'");
instance.TriggerSerialPollBegin();
_serialPort.WriteLine(command);
instance._noResponseTimer.Start();
return true;
}
else
{
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;
instance.TriggerSerialStateChangeEvent();
try {
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;
instance.TriggerSerialStateChangeEvent();
goto retry_send_message;
}
Thread.Sleep(1000);
}
System.Diagnostics.Debug.WriteLine
($"Reconnect serial failed. {retryCounter}");
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.
private static void SerialDataReceivedHandler
(object sender, System.IO.Ports.SerialDataReceivedEventArgs e)
{
if (instance == null) return;
string data = _serialPort.ReadLine();
if (data.StartsWith("i,"))
{
instance._noResponseTimer.Stop();
SQMLatestMessageReading.SetReading("ix", data);
}
else if (data.StartsWith("r,"))
{
instance._noResponseTimer.Stop();
SQMLatestMessageReading.SetReading("rx", data);
}
else if (data.StartsWith("u,"))
{
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.
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.
private static void AcceptCallback(IAsyncResult ar)
{
if (instance == null) return;
try
{
_clientCount++;
instance.TriggerStateChangeEvent();
if (ar.AsyncState == null) return;
Socket listener = (Socket)ar.AsyncState;
Socket handler = listener.EndAccept(ar);
instance.allDone.Set();
StateObject state = new();
state.WorkSocket = handler;
while (_serverState == Enums.ServerRunningStates.Running)
{
if (state.WorkSocket.Available > 0)
{
handler.Receive(state.buffer);
state.sb.Clear();
state.sb.Append
(Encoding.UTF8.GetString(state.buffer));
state.buffer = new byte[StateObject.BufferSize];
}
if (state.sb.Length > 0)
{
int byteCountSent = 0;
string? latestMessage;
if (state.sb.ToString().StartsWith("ix"))
{
instance._serverLastMessage =
$"{state.WorkSocket.RemoteEndPoint}: ix";
instance.TriggerServerMessageReceived();
SQMLatestMessageReading.GetReading("ix", out latestMessage);
latestMessage += "\n";
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);
}
}
}
catch (SocketException ex)
{
System.Diagnostics.Debug.WriteLine
($"Socket Error: {ex.ErrorCode}, {ex.Message}");
if (instance != null)
{
_clientCount--;
if (_clientCount < 0)
{
_clientCount = 0;
}
instance.TriggerStateChangeEvent();
}
}
catch (ObjectDisposedException ex)
{
System.Diagnostics.Debug.WriteLine(ex.ToString());
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine(ex.ToString());
throw;
}
}
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.
private void LoggingTimer_Tick(object? sender, EventArgs e)
{
if (!serialConnected)
{
return;
}
bool hasData = false;
DataStoreDataPoint datapoint = new();
if (SQMLatestMessageReading.HasReadingForCommand("ux") &&
SQMLatestMessageReading.HasReadingForCommand("rx"))
{
SQMLatestMessageReading.GetReading("rx", out string? rxMessage);
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; }
}
if (hasData)
{
AddDataPoint(datapoint);
}
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)
{
data.RemoveAt(0);
}
data.Add(datapoint);
store.TriggeDataStoreRecordAdded();
}
Trending
The trend user control is built up of PictureBox
es. 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.
private void UpdatePoints()
{
updateInProgress = true;
while (localBuffer.Count > 0)
{
if (backgroundTrendRecord == null)
{
backgroundTrendRecord = new(1, pictureBoxTrend.Height);
labelMax.Visible = true;
labelValue34.Visible = true;
labelMid.Visible = true;
labelValue14.Visible = true;
labelMin.Visible = true;
}
for (int i = 0; i < backgroundTrendRecord.Height; i++)
{
backgroundTrendRecord.SetPixel(0, i, pictureBoxTrend.BackColor);
}
double subInterval = backgroundTrendRecord.Height / 20.0;
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));
}
}
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;
}
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));
if (backgroundMasterTrend == null)
{
backgroundMasterTrend = new(1, pictureBoxTrend.Height);
}
else
{
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;
DataStoreDataPoint data = localBuffer.First();
int y;
int penRaw = 0;
int penAvg = 0;
int penTemp = 0;
int penNELM = 0;
if (layerTempRecord == null)
{
layerTempRecord = new(1, pictureBoxTrend.Height);
}
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;
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;
if (layerTemp == null)
{
layerTemp = new(1, pictureBoxTrend.Height);
}
else
{
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.
private void GenerateCompletedTrend()
{
if (backgroundMasterTrend == null)
{
return;
}
Bitmap bitmapFinal = new(backgroundMasterTrend.Width,
backgroundMasterTrend.Height);
using Graphics gFinal = Graphics.FromImage(bitmapFinal);
{
gFinal.DrawImage(backgroundMasterTrend, 0, 0);
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;
if (autoScroll)
{
horizontalTrendScroll.Value = horizontalTrendScroll.Maximum;
pictureBoxTrend.Left = pictureBoxPens.Left -
pictureBoxTrend.Width;
}
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";
}
else
{
buttonRunStop.Text = "\u23F5";
}
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.
private void DrawMarkers(int penRaw, int penAvg, int penTemp, int penNELM)
{
Graphics gPen = pictureBoxPens.CreateGraphics();
{
gPen.Clear(pictureBoxTrend.BackColor);
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:
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.
References
History
- V1.0 - 23rd May 2022 - First article release