[Перевод] Расшаривание USB-устройства по нескольким клиентам через TCP

o-ezx-agxekwazx-yo72padp84a.png


Будучи увлечённым астрофотографом, я использовал в комплекте оборудования USB Sky Quality Meter (измеритель качества неба), и однажды мне потребовалось организовать к нему общий доступ от нескольких профильных приложений. Однако я не хотел заменять его на Ethernet-версию или докупать такой для каждой установки, поэтому решил просто написать собственную программу.

Предыстория


Спустя 10 лет с последнего момента использования С# (это было приложение UltraDynamo на соревновании Intel App в 2012), я решил тряхнуть стариной и обновил Visual Studio до последней версии, чтобы попробовать решить возникшую передо мной задачу. Так что код получился не особо аккуратный, с неудачной структурой и т.д., но он работает, а это сейчас для меня самое главное — так как задача была решена!

Введение


Астрофотографией я увлёкся относительно недавно — в 2022 году. Занятие это непростое, но необходимые усилия оправдываются сторицей, и в свободное время я погружаюсь в него с головой. Да, иногда оборудование намекает на то, что ему бы не помешала обработка кувалдой, но именно в этом и есть вызов. Честно говоря, за последние два года мой запал настолько разыгрался, что изначальный набор из телескопа и камеры превратился в глобальный комплект оборудования — я раскошелился с кредитки на покупку телескопа, охлаждаемых астрокамер, новых монтировок и даже построил в саду обсерваторию.

По мере прироста нового оборудования я перешёл от одной к двум полностью функциональным установкам, которые использую постоянно. У меня есть из чего собрать и третью, но управления двумя по вечерам и так хватает за глаза.

Вот здесь и возникла тема для текущей статьи.

В чём суть


Среди приобретённого мной оборудования был Unihedron Sky Quality Meter, устройство, которое мониторит яркость неба, позволяя следить за изменением его освещённости, и подбирать достаточно тёмное время для ведения съёмки (мануал). При этом также можно наблюдать, как на небосвод влияет яркость Луны, и оценивать общую засветку.

В моём распоряжении была USB-версия этого устройства, которая подключается к ПК/ноутбуку, после чего специальное ПО для астрофотографии вроде APT или N.I. N.A. может считывать передаваемые им данные, встраивая их в заголовок файла формата FITS. Этот файл в последствии можно просмотреть, чтобы проверить, почему конкретный кадр или серия кадров имеют разную чёткость и прочие характеристики — возникли облака, сосед включил фонарь и т.д.

С течением времени я собрал второй комплект для астрофотографии, включая телескоп, и не хотел идти покупать ещё один измеритель освещённости, либо заменять имеющийся на Ethernet-версию.

Изначально я попробовал решить проблему с помощью небольшого приложения, которое нашёл в сети — SerialToIP. Однако этот инструмент оказался ненадёжен и подвержен различным ошибкам.

Производители Sky Quality Meter (SQM) опубликовали протокол последовательной передачи, используемый этим устройством, поэтому я решил, что попробую написать собственное приложение, которое сможет обслуживать запросы от клиентов по TCP и считывать данные с устройства, возвращая на эти запросы необходимую информацию.

Для взаимодействия между различными видами астрономического оборудования была разработана платформа универсальных стандартов для астрономии ASCOM. Dizzy Astronomy написал для устройств SQM драйвер ASCOM, который позволяет специализированным инструментам (тем же APT и N.I. N.A.) считывать измеряемые SQM данные по TCP. Причём этот драйвер можно настроить для считывания непосредственно с USB или Ethernet-устройств.

Описываемое в этой статье приложение располагается между драйвером ASCOM и устройством, чтобы представлять его в виде Ethernet-версии, тем самым позволяя работать с ним нескольким клиентам.

Само USB-устройство подключается к компьютеру-хосту первой установки астросъёмки (Rig 1), после чего программное обеспечение каждой установки направляется на настроенный IP/Port хоста Rig 1.

Как это приложение выглядит?


Прежде чем переходить к деталям реализации, покажу вам его наглядно. Вот основной экран:

qiwwyt4jzi_3362dqa-50taffze.png

Здесь располагается меню для доступа к различным настройкам, а две верхние панели дают возможность подключать/отключать USB-устройство и запускать/останавливать серверную часть.

Оба этих раздела также показывают последнее необработанное сообщение, полученное от устройства или от клиента.

Диаграмма отражает историю точек данных, позволяя наглядно наблюдать изменение освещённости, температуры и вычисляемой «Naked Eye Limiting Magnitude» (предельной звёздной величины, видимой невооружённым глазом).

Помимо состояния неба, программа отражает статус подключения и сервера, выполнение запроса или передачу ответа, а также количество записей в пуле памяти.

Экраны конфигурации являются стандартным пунктом настройки, где вы выставляете COM-порты, ip-порты, цвета диаграммы, каталоги для логирования и количество удерживаемых в памяти точек данных.

Структура приложения


Так выглядит общая блок-схема приложения:

_5_l_ulnk9mzzxchiacsims7aze.png

Передачу данных между компонентами я реализовал с помощью системы событий, разбив основные элементы на отдельные классы, при необходимости выполняющиеся в собственных потоках.

  • SerialManager обрабатывает коммуникацию с USB-устройством;
  • NetworkManager обрабатывает клиентскую коммуникацию;
  • SettingsManager координирует все настройки приложения, сохраняя их на диске.
  • DataStore прослушивает сообщения событий для сохранения записей в памяти и, если установлено, на диск;
  • Trend представляет пользовательское управление, которое работает поверх основной формы.


Теперь всё по порядку.

SerialManager


После настройки и подключения последовательного порта SerialManager начинает опрашивать USB-устройство по указанному внутреннему адресу. Опрос реализуется с использованием простого Timer. При этом в протоколе задействуются три разных команды: ix, rx, ux. За выполнение опроса отвечает метод SendMail.

Немного о командах:

  • 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:

            //Информация устройства
            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;
            }
        }


В коде выше видно, что при отправке команды мы устанавливаем флаг, и таймер начинает проверять получение валидного ответа в течение заданного промежутка времени. Задержка возможна, к примеру, когда COM-порт успешно открылся, но передаваемые данные получателем не распознаются.

Полученный ответ парсится и его содержимое проверяется. После эти данные упаковываются в EventArgs, и вызывается событие, позволяющее DataStore поместить их в хранилище, по одной записи для каждого ответа.

Типичный ответ выглядит так:

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


В данном примере первая буква означает исходную команду, на которую возвращён ответ, здесь это rx. При этом ответ парсится, и из него извлекается только нужная информация. В текущем ответе это будет 06.7 и 039.4C.

  • 06.7 — это показатель качества неба, отражаемый в величине, поделённой на секунду дуги в квадрате.
  • 039.4C — это температура в градусах Цельсия.


Завершаются ответы управляющей последовательностью \r\n.

Полученные данные прогоняются через обработчик событий, который закрепляется за SerialPort при открытии соединения.

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

            string data = _serialPort.ReadLine();

            //Обновляем хранилище последних показаний SQM

            if (data.StartsWith("i,"))
            {
                //команда ix
                instance._noResponseTimer.Stop();
                SQMLatestMessageReading.SetReading("ix", data);
            }
            else if (data.StartsWith("r,"))
            {
                //команда rx
                instance._noResponseTimer.Stop();
                SQMLatestMessageReading.SetReading("rx", data);
            }
            else if (data.StartsWith("u,"))
            {
                //команда ux
                instance._noResponseTimer.Stop();
                SQMLatestMessageReading.SetReading("ux", data);

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


NetworkManager


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


При подключении клиента обрабатывается обратный вызов, запрос клиента проверяется на наличие актуальных команд (ix, rx или ux), и в ответ отправляются последние данные из хранилища.

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")) //Encoding.UTF8.
                                                           //GetString(state.buffer)
                        {
                            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);
                    }
                }
                //handler.BeginReceive(state.buffer, 0, StateObject.BufferSize, 0,
                // новый 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 = подключение было сброшено .NET или провайдером сокета
                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


DataStore — это просто список точек данных. Он прослушивает события SerialData и вносит в хранилище последние данные. Здесь также присутствует таймер логирования, который производит запись последних данных на диск с установленным интервалом.

private void LoggingTimer_Tick(object? sender, EventArgs e)
        {
            if (!serialConnected)
            {
                //Выйти, если нет подключения
                return;
            }

            //Создаём точку данных
            //Проверяем, чтобы в снимке данных были и rx, и ux
            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; }
            }

            //Логирование в память
            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();
        }


Построение диаграммы


Пользовательский контроль диаграммой реализован на основе PictureBox (графического окна). Здесь используется одно такое окно для самой диаграммы и другое для её маркеров справа.

Строится диаграмма из растровых слоёв, генерируемых в фоновом режиме. Базовый слой содержит линии сетки, положение которых итеративно вычисляется, после чего они отрисовываются.

Всего здесь четыре слоя данных, по одному для каждого из отображаемых параметров, что позволяет при необходимости легко включать и отключать их. Базово слои прозрачные, и на них уже наносятся точки данных. При отрисовке одна запись занимает один пиксель и добавляется в мастер-слой, где отражает показания соответствующего параметра.

После отрисовки все четыре слоя накладываются поверх базового, загружаются в графический блок и показываются пользователю.

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

                //Рисуем сетку
                //int mainInterval = backgroundTrendRecord.Height / 4;
                //int subInterval = backgroundTrendRecord.Height / 40;
                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));
                    }
                }

                //Main

                //Увеличиваем 2 вертикальных счётчика позиций
                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;     //Сохраняем y-координату каждой точки для отрисовки их вертикальных линий связи

                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;


Код выше только показывает фоновый цвет и температуру, другие три параметра обрабатываются точно таким же образом.

Здесь важно сохранять позицию последней отрисованной точки данных для каждого параметра, чтобы иметь возможность связывать смежные точки вертикальными линиями.

При этом слои также обрезаются, чтобы не превышать установленное количество точек данных.
Следующие два метода генерируют полную диаграмму и отображают её:

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


Когда изображение диаграммы превысит ширину видимого графического блока, мы сдвигаем картинку и отображаем полосу прокрутки, по которой пользователь сможет перемещаться.
Для отрисовки маркеров справа от диаграммы используются закрашенные полигоны.

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


Видео-демонстрация


Ресурсы


Вся база кода размещена на GitHub, так как я сделал это приложение доступным для сообщества астрономов.

xbo4gmrlicdllfwrmtuypqrlcgg.jpeg

© Habrahabr.ru