GPS и сбоку бантик. Многофункциональный GPS Logger. Часть 2
Всем привет! Некоторое время назад я загорелся идеей проапгрейдить свой верный и любимый GPS логгер Holux M241. Можно было бы поискать чего нибудь интересное на рынке, что могло бы удовлетворить мои потребности. Но мне было интереснее копнуть в сторону микроконтроллеров, NMEA GPS протокола, USB и SD Card премудростей, тем самым построив устройство своей мечты.
Что же именно я строю я детально описал в первой части. На том этапе я пристреливался к технологиям — щупал Arduino в контексте сравнительно большого проекта. Оказалось есть масса нюансов, которые в обычных туториалах особо не затрагивают. В комментариях я получил массу интересного инпута, за что очень благодарен читателям. Надеюсь и сегодня Вы найдете чего нибудь интересненькое.
Это вторая статья из серии. Как и предыдущая она является своего рода журналом постройки. Я стараюсь описывать технические решения, которые я принимаю по ходу работы над проектом. Сегодня мы будем подключать GPS. А еще переходить на более взрослые технологии — FreeRTOS и микроконтроллер STM32. Ну и как всегда будем дизассемблировать прошивку и смотреть что же там написано.
Прошу под кат.
GPS’им
К этому времени у меня уже был каркас приложения. Все крутилось на Arduino Nano на контроллере ATMega328. Пришло время подключать мой GPS приемник Beitan BN-880.
У меня есть некоторое предвзятое отношение к UART как к низкоскоростному протоколу из прошлого века. Разумом то, конечно, понимаю — интерфейс простой как 3 копейки, работает на всем что движется. Что еще нужно? Еще я предвзято отношусь к текстовым протоколам — сообщения же еще парсить нужно. Почему бы данные не гонять в бинарном виде? Да еще пакетами? Все равно их человек не читает. А бинарные пакеты могли бы значительно упростить обработку. Ну то я так, жужжу.
Увидев ноги SDA и SCK торчащие из модуля мне захотелось к ним прицепиться. Прицепился и…. понял, что данные получить не так то просто. Я даже и не знаю как. Если используется UART, то GPS приемник просто насыпает сообщения, а получатель парсит что ему нужно. I2C же передача инициируется только со стороны хоста. Т.е. нужно сформировать некий запрос, чтобы получить ответ. Но какой?
Гуглеж на тему BN-880 I2C в течении пары часов ничего полезного не дал. Народ просто использует UART, а большая часть ссылок вела на форумы квадрокоптерщиков и обсуждались там в основном квадрокоптерные проблемы.
На даташиты выйти было не так просто. Т.е. не совсем понятно было на какой модуль искать даташит. По косвенным признакам я выяснил, что за GPS отвечает модуль UBlox NEO-M8N. Оказалось что эта штука умеет такое количество фич, что мама не горюй (там даже встроенный одометр и логгер есть). Но читать нужно было ни много ни мало 350 страниц.
Полистав туда-сюда даташит я понял, что с наскоку этот модуль не взять. Пришлось наступить себе на горло и подключить к уже проверенному UART. И тут же вступить в другую проблему: на ардуине UART только один, и тот торчит в сторону компа (заливать прошивки). Пришлось смотреть в сторону библиотеки SoftwareSerial.
Написал простейший «переливатор» сообщений из порта GPS в UART.
SoftwareSerial gpsSerial(10, 11); // RX, TX
void setup()
{
Serial.begin(9600);
gpsSerial.begin(9600);
}
void loop()
{
if (gpsSerial.available()) {
Serial.write(gpsSerial.read());
}
}
Посыпались сообщения, но спутники словить так и не смог. Хотя время было правильное.
$GNRMC,203954.00,V,,,,,,,,,,N*6A
$GNVTG,,,,,,,,,N*2E
$GNGGA,203954.00,,,,,0,00,99.99,,,,,,*71
$GNGSA,A,1,,,,,,,,,,,,,99.99,99.99,99.99*2E
$GNGSA,A,1,,,,,,,,,,,,,99.99,99.99,99.99*2E
$GPGSV,1,1,02,02,,,21,08,,,09*7B
$GLGSV,1,1,00*65
$GNGLL,,,,,203954.00,V,N*5D
GPS пролежал больше часа у окна 21 этажа прежде чем выдал вменяемые координаты. Причем большая часть обзора у меня не закрыта высокоэтажной застройкой. Есть подозрение, что на окнах нанесено некое напыление, которое ухудшает качество сигнала. Во всяком случае возле открытого окна спутники, как будто, ловятся быстрее.
Раз есть сигнал, значит можно парсить. На просторах интернета первой попалась библиотека TinyGPSPlus. Подключил не без хаков. В ArduinoIDE все работало, но в Atmel Studio не хотело. Пришлось вручную прописать пути к библиотеке.
Но тут вылезла проблема. На простых скетчах из примеров TinyGPS+ все работало. Но когда я подключил это в свой проект с дисплеем и кнопками все сломалось. Девайс ощутимо тупил, явно проскакивая отрисовки экрана. В мониторе порта я начал замечать покореженные сообщения от GPS.
Первое предположение было в том, что SoftwareSerial весьма серьезно жрал ресурсы процессора. А значит SoftwareSerial нужно отправлять в топку, т.к. для надежной коммуникации с GPS он не подходит (во всяком случае в том виде, в котором он в примерах). Я даже хотел вывернуть схему наизнанку: GPS подключить к аппаратному UART«у ардуины, а софтварный сериал использовать для дебага (хотя при наличии экрана дебажиться через UART может даже и не потребуется). Но при такой схеме загружать прошивку через UART уже не получится. Пришлось достать программатор USBAsp.
Но чуть позже я понял, что SoftwareSerial штука хоть и прожорливая, но в данном случае проблема совсем не в нем, а в функции рисования. Отрисовка текущего экрана занимает 50–75 мс (плюс еще чуток на накладные расходы). SoftwareSerial работает на прием по прерыванию на ноге контроллера и много, в общем то, потреблять не должен. Но у него приемный буфер всего 64 байта, которые даже на скорости 9600 заполняются за 60 мс. Получается, пока программа занимается отрисовкой экрана, часть сообщения от GPS уже успевает пройти мимо.
В первой половине статьи у меня получается очень много текста. Разбавлю ка я их картинками. На этом экране отображается текущая высота и вертикальная скорость
ARMируем
Итак. С текущим подходом я уперся сразу в несколько ограничений:
- Флеш и ОЗУ. Не так чтобы много было занято, но приходилось постоянно об этом помнить
- Всего один UART. Дополнительный SoftwareSerial ощутимо потребляет ресурсы процессора.
- В один поток все делать явно не получается. Нужно думать о распараллеливании задач.
А еще нужно было проектировать с расчетом на будущее — мне же еще светит подключение USB и SD карты.
После выхода предыдущей части я получил много комментариев, что Ардуино отстой и будущее за ARM и контроллерами STM32. Мне не очень хотелось уходить с платформы Ардуино. Как я уже говорил фреймворк у них достаточно простой и понятный, да и контроллеры ATMega я тоже хорошо знаю.
В тоже время переход на STM32 скорее всего означал бы смену платформы в целом, микроконтроллера, компилятора, фреймворка, библиотек, IDE и кто знает чего еще. Т.е. почти всего и сразу. Для проекта это означало бы полностью остановится, долго штудировать документацию, изучать разные примеры, и только потом начинать переписывать все с нуля.
Я начал щупать выходы из ситуации, прислушиваясь к комментаторам первой части. Хотелось найти решение которое решало ограничения, давало некий задел на будущее, но при этом не требовало огромных ресурсов на переезд всего и сразу. Вот несколько (в общем то независимых) вещей с которыми я поковырялся.
- Подключил клон Sparkfun Pro Micro на ATMega32u4 (3.3В, 8МГц). В нем я хотел пощупать аппаратный USB. У меня довольно много ушло времени вообще завести эту штуковину. Штатный бутлоадер как то не очень хотел заводиться как ардуино, а уж fuse биты и вовсе были выставлены каким то загадочным образом. В итоге с помощью USBAsp вшил бутлоадер от Arduino Leonardo и все завелось.
- Пришла отладочная плата на ATMega64. В ней в 2 раза больше памяти (и флеша и ОЗУ) и 2 uart. В принципе снимает ограничения. К сожалению к плате не прилагается схема и какой там кварц стоит тоже не ясно. Пока отложил.
- Попробовал пощупать порт FreeRTOS под AVR. Но тут каку подложила Atmel Studio. Оказалось что у нее есть 2 вида проектов. В одном студия работает в режиме Ардуино, но в этом случае практически ничего нельзя менять в настройках проекта. Т.е. банально нельзя даже положить FreeRTOS в поддиректорию и прописать include path. Оно умеет только складывать все файлы в одну кучу, что лично меня бы раздражало.
Второй вариант — тип проекта Generic C++ Executable. Подразумевается, что писать нужно на голом C++. Тут уже можно конфигурить как душе угодно. Но во-первых нужно как то прикрутить ардуиновский фреймворк, а во-вторых непонятно как прикрутить заливатор прошивки в контроллер. Avrdude упорно не хотел перегружать микроконтроллер в бутлоадер (хотя командную строку я подсмотрел у ArduinoIDE с помощью ProcessMonitor). У меня есть USBasp, но при наличии USB порта прямо на плате шиться через программатор как то не комильфо.
- Наконец я распаял гребенку на плату с STM32F103C8T6 и по инструкции установил STM32duino. К моему удивлению моргалка на светодиодах сразу заработала. К еще большему удивлению портирование моего проекта на новый контроллер заняло меньше 10 минут!!! Всего то пару инклудов поменять да номера пинов поправить.
Это было то, что нужно. Я получал мощь STM32 (да! Функция рисования теперь занимала всего 18 мс!) и при этом я мог продолжать пользоваться фреймоворком ардуино. Это дало возможность продолжать работу над проектом, при этом по необходимости плавно погружаться в новую платформу, почитывая в метро даташит на микроконтроллер.
Прирост флеша, на самом деле, весьма призрачное улучшение. Проект как занимал половину флеша на ATmega32, так и занимает почти половину на новом STM32 (ну ладно, 26к из 64к). Так что расслабляться не стоило. Тем более (как пишут в интернетах) скомпилированный код несколько более размашист и заполняет флеш быстрее чем на AVR. Так что на всякий случай заказал платку с 128к флеша.
Правда тут меня ждал еще один сюрприз. Народ в интернете пишет, что хотя контроллер по даташиту имеет 64к флеша на борту, по факту можно использовать 128к. Т.е. похоже ST производит один и тот же чип, только часть маркирует как STM32F103C8T6, а другую как STM32F103CBT6 (такой же контроллер, но с 128к флеша).
К слову (follow up после предыдущей статьи). В архитектуре ARM и флеш и ОЗУ находятся в одном адресном пространстве и читаются единым образом. Поэтому танцы с бубном и объявление констант с помощью PROGMEM уже не нужны. Поубирал ради чистоты кода. Таблицы виртуальных функций тоже никуда копировать не нужно, т.к. они также находятся в том же адресном пространстве.
Еще одна картинка для разбавления текста. Слева направо: направление движения (сейчас никуда не движемся), текущая скорость, текущая высота. Экран честно слизан с аналогичного у Holux M241
FreeRTOS’им
В комплекте STM32duino так же обнаружился порт FreeRTOS под мой контроллер (причем аж два — 7.0.1 и 8.2.1). Примеры с минимальными правками так же заработали. Так что можно было переходить на FreeRTOS не переписывая значительную часть проекта.
Прочитав пару статей (раз, два) я осознал какая мощь теперь мне доступна — потоки, мютексы, очереди, семафоры и прочая синхронизация. Все как на больших компах. Главное все правильно спроектировать.
Несмотря на то, что основной проблемой у меня был GPS, я все таки решил начать с чего попроще — кнопок. В каком то смысле FreeRTOS намного упрощает код — каждый поток может заниматься некоторой определенной задачей, и, при необходимости, нотифицировать другие потоки. Так, задача обслуживания кнопок отлично ложилась в эту идеологию — слушай себе кнопки и ни на что не отвлекайся. А уж как нажмется что нибудь — нотифицируй.
static void selButtonPinHandler()
{
static uint32 lastInterruptTime = 0;
if(digitalRead(SEL_BUTTON_PIN)) // Falling edge
{
uint32 cur = millis();
uint32 pressDuration = cur - lastInterruptTime;
Serial.print("DePressed at ");
Serial.println(lastInterruptTime);
if(pressDuration > LONG_PRESS_TIMEOUT)
Serial.println("Sel Long Press");
else
if(pressDuration > SHORT_CLICK_TIMEOUT)
Serial.println("Sel Short Click");
else
{
Serial.print("Click was too short: ");
Serial.println((int)pressDuration);
}
}
lastInterruptTime = millis();
if(!digitalRead(SEL_BUTTON_PIN)) // Raising edge
{
Serial.print("Pressed at ");
Serial.println(lastInterruptTime);
}
}
void initButtons()
{
// Set up button pins
pinMode(SEL_BUTTON_PIN, INPUT_PULLUP); // TODO: using PullUps is an AVR legacy. Consider changing this to pull down
pinMode(OK_BUTTON_PIN, INPUT_PULLUP); // so pin state match human logic expectations
attachInterrupt(SEL_BUTTON_PIN, selButtonPinHandler, CHANGE);
}
Вместо принтов должны были быть отсылки сообщений о нажатой кнопке.
Но если честно, получилось громоздко (это обработка только одной кнопки) да еще и ужасно глючно. Наблюдались какие то ложные срабатывания, или наоборот не срабатывания. Такое впечатление, что функция millis () как то неправильно работала и могла возвращать одинаковые значения на протяжении довольно большого промежутка времени.
Напомню. В основном цикле программы у меня была большая стейт машина, которая управляла дисплеем и слушала кнопки. Добавление какой нибудь логики сопровождалось перекраиванием половины кода, а понять как это работает просто глядя на код под силу было только гуру программирования стейт машин. Но раз уж у меня есть РТОС, то все вышло значительно проще.
// Pins assignment
const uint8 SEL_BUTTON_PIN = PC14;
const uint8 OK_BUTTON_PIN = PC15;
// Timing constants
const uint32 DEBOUNCE_DURATION = 1 / portTICK_PERIOD_MS;
const uint32 LONG_PRESS_DURATION = 500 / portTICK_PERIOD_MS;
const uint32 VERY_LONG_PRESS_DURATION = 1000 / portTICK_PERIOD_MS;
const uint32 POWER_OFF_POLL_PERIOD = 1000 / portTICK_PERIOD_MS; // Polling very rare when power is off
const uint32 IDLE_POLL_PERIOD = 100 / portTICK_PERIOD_MS; // And little more frequent if we are on
const uint32 ACTIVE_POLL_PERIOD = 10 / portTICK_PERIOD_MS; // And very often when user actively pressing buttons
QueueHandle_t buttonsQueue;
// Reading button state (perform debounce first)
inline bool getButtonState(uint8 pin)
{
if(digitalRead(pin))
{
// dobouncing
vTaskDelay(DEBOUNCE_DURATION);
if(digitalRead(pin))
return true;
}
return false;
}
/// Return ID of the pressed button (perform debounce first)
ButtonID getPressedButtonID()
{
if(getButtonState(SEL_BUTTON_PIN))
return SEL_BUTTON;
if(getButtonState(OK_BUTTON_PIN))
return OK_BUTTON;
return NO_BUTTON;
}
// Initialize buttons related stuff
void initButtons()
{
// Set up button pins
pinMode(SEL_BUTTON_PIN, INPUT_PULLDOWN);
pinMode(OK_BUTTON_PIN, INPUT_PULLDOWN);
// Initialize buttons queue
buttonsQueue = xQueueCreate(3, sizeof(ButtonMessage)); // 3 clicks more than enough
}
// Buttons polling thread function
void vButtonsTask(void *pvParameters)
{
for (;;)
{
// Wait for a button
ButtonID btn = getPressedButtonID();
if (btn != NO_BUTTON)
{
// Button pressed. Waiting for release
TickType_t startTime = xTaskGetTickCount();
while(getPressedButtonID() != NO_BUTTON)
vTaskDelay(ACTIVE_POLL_PERIOD);
// Prepare message to send
ButtonMessage msg;
msg.button = btn;
// calc duration
TickType_t duration = xTaskGetTickCount() - startTime;
if(duration > VERY_LONG_PRESS_DURATION)
msg.event = BUTTON_VERY_LONG_PRESS;
else
if(duration > LONG_PRESS_DURATION)
msg.event = BUTTON_LONG_PRESS;
else
msg.event = BUTTON_CLICK;
// Send the message
xQueueSend(buttonsQueue, &msg, 0);
}
// TODO: Use different polling periods depending on global system state (off/idle/active)
vTaskDelay(ACTIVE_POLL_PERIOD);
}
}
Получилось весьма компактно и понятно. Функции все очень линейные. Просто в цикле опрашиваем кнопки и на основе длительности нажатия отсылаем соответствующее сообщение.
Я решил что у меня будет 3 вида длительностей нажатия:
- Короткое для выбора соответствующего пункта меню
- Длинное для специального действия (например сброс выбранного параметра)
- Очень длинное нажатие для включения и выключения устройства
Я, кстати, решил подключить кнопки не к плюсу, а к минусу. Естественно pull-up резисторы заменил на pull-down. Я не силен в электронике и могу тут ошибаться, но в целом я руководствовался следующими соображениями:
- В отпущенном положении кнопки пин прижимается к нулю, а значит ток не течет (пускай даже мизерный)
- При чтении значения с пина значение получается неинвертированным: 1 если кнопка нажата, 0 — отпущена
ScreenManager также значительно упростился. Больше небыло необходимости в глобальном состоянии дисплея. Поток отрисовки занимается исключительно отрисовкой и управляется сообщениями от кнопок. Он просто ждал сообщений в очереди и отрабатывал полученные команды. Причем сам цикл ожидания так же был сделан через очередь с помощью таймаута в функции xQueueReceive. Т.е. функция ждет сообщения, а если ничего не происходит долгое время — просто отрисовывает экран как есть
void vUserInteractionTask(void *pvParameters)
{
for (;;)
{
// Poll the buttons queue for an event. Process button if pressed, or show current screen as usual if no button pressed
ButtonMessage msg;
if(xQueueReceive(buttonsQueue, &msg, DISPLAY_CYCLE))
processButton(msg);
// Do what we need for current state
drawDisplay();
}
}
Получилось, на мой взгляд, очень элегантно. Позже я сюда добавил выключение экрана после некоторого таймаута (экономия батареи), но код существенно не усложнился.
Обработка кнопок так же тривиальна — просто парсим сообщение и вызываем необходимую функцию
void processButton(const ButtonMessage &msg)
{
if(msg.button == SEL_BUTTON && msg.event == BUTTON_CLICK)
getCurrentScreen()->onSelButton();
if(msg.button == OK_BUTTON && msg.event == BUTTON_CLICK)
getCurrentScreen()->onOkButton();
// TODO: process long press here
}
Функция showMessageBox () так же сильно упростилась и стала теперь совсем линейной
void showMessageBox(const char * text)
{
//Center text
uint8_t x = 128/2 - strlen_P(text)*6/2;
// Draw the message
display.clearDisplay();
display.setFont(NULL);
display.drawRect(2, 2, 126, 30, 1);
display.setCursor(x, 12);
display.print(text);
display.display();
// Wait required duration
vTaskDelay(MESSAGE_BOX_DURATION);
}
И напоследок. Что это за устройство, если у него нет моргающей лампочки? Нужно исправить. Как бы это ни смешно было, но по моргающему диоду удобно следить работает ли еще устройство, или давно повисло.
void vLEDFlashTask(void *pvParameters)
{
for (;;)
{
vTaskDelay(2000);
digitalWrite(PC13, LOW);
vTaskDelay(100);
digitalWrite(PC13, HIGH);
}
}
Опять GPS’им
Наконец, настало время терзать GPS. Теперь уже нет проблемы одновременно слушать GPS и делать все остальное. Для начала я опять написал переливатор:
void initGPS()
{
// GPS is attached to Serial1
Serial1.begin(9600);
}
void vGPSTask(void *pvParameters)
{
for (;;)
{
while(Serial1.available())
{
int c = Serial1.read();
gps.encode(c);
Serial.write(c);
}
vTaskDelay(5);
}
}
Но тут возникла проблема. Сообщения формально парсились, только вот даже время выкусить оттуда не получилось. Курение исходников TinyGPS и документации на приемник показало небольшое несоответствие сообщений от GPS модуля и тем что умеет парсить библиотека.
Модуль UBlox реализует некое расширение протокола NMEA. Каждое сообщение начинается с пятибуквенного идентификатора сообщения.
$GNGGA,181220.00,,,,,0,00,99.99,,,,,,*70
Первые 2 буквы кодируют подсистему, которая приготовила данные: GP для GPS, GL для GLONASS, GA для GALILLEO. А вот если используется комбинация систем позиционирования то сообщения будут начинаться с GN.
Библиотека TinyGPS+ на такое рассчитана не была — она умела парсить только сообщения GP. Пришлось ее чуток подправить — поменял соответствующую строку в парсере и время на экране побежало. Только вот это все попахивало каким то хаком.
Товарищ подсказал альтернативу — библиотеку NeoGPS. Это намного более фичастая библиотека. Помимо того, что она умеет парсить сообщения с разными префиксами, она еще позволяет парсить информацию о спутниках (лично мне нравятся такие штуки в GPS приемниках). Еще стоит отметить, что библиотека жутко конфигуряемая — можно включить/выключить парсинг отдельных сообщений и тем самым регулировать потребление памяти в зависимости от задач.
Подключить библиотеку к stm32duino труда не составило, правда чуток подпилить все же пришлось. Но как всегда в примерах все просто и понятно, а в реальном проекте оно сразу не заработало. В частности было неясно в какой момент времени правильно читать из GPS. Вот, например, попытка вычитать данные о спутниках.
for (;;)
{
while(Serial1.available())
{
int c = Serial1.read();
Serial.write(c);
gpsParser.handle(c);
}
if(gpsParser.available())
{
memcpy(satellites, gpsParser.satellites, sizeof(satellites));
sat_count = gpsParser.sat_count;
}
vTaskDelay(10);
}
Время от времени парсер говорит, что данные прибыли — забирайте. Координаты всегда приезжают нормально, а вот со спутниками беда. Забираю, а там нули. Или не нули. Как повезет.
Оказалось нужно было внимательно прочитать документацию. Все дело в дизайне библиотеки. Во имя экономии памяти данные раскладываются по переменным по ходу парсинга. При чем побайтово — пришел байт, обновили переменную. Данные приходят пакетами по несколько сообщений. Библиотека NeoGPS должна знать когда начинается новый пакет, чтобы обнулить внутренние переменные. За это отвечает параметр конфигурации LAST_SENTENCE_IN_INTERVAL
//------------------------------------------------------
// Select which sentence is sent *last* by your GPS device
// in each update interval. This can be used by your sketch
// to determine when the GPS quiet time begins, and thus
// when you can perform "some" time-consuming operations.
#define LAST_SENTENCE_IN_INTERVAL NMEAGPS::NMEA_RMC
Так вот сообщение RMC у меня приходит самым первым в пакете сообщений. Получается что мой код мог прочитать частично распаршеные данные (Возможно это были данные предыдущих пакетов, которые еще не успели обнулится). Или вычитывать нули, если прочитать в неудачное время. Лечится довольно просто: указываем, что в каждом пакете от GPS модуля последнее сообщение у нас GLL.
Спутников много, а фикса все нет и нет. Сверху вниз: количество спутников (отслеживаемые vs неотслеживаемые — не знаю что это значит), HDOP/VDOP, Статус GPS сигнала (словило/не словило)
Кстати, с библиотекой в комплекте обнаружились довольно удобные функции по работе с датой и временем. Так, например, очень легко было прикрутить часовой пояс. Я только храню временнОе смещение в минутах, а остальное легко высчитать по ходу.
void TimeZoneScreen::drawScreen() const
{
// Get the date/time adjusted by selected timezone value
gps_fix gpsFix = gpsDataModel.getGPSFix();
int16 timeZone = getCurrentTimeZone();
NeoGPS::time_t dateTime = gpsFix.dateTime + timeZone * 60; //timeZone is in minutes
...
printNumber(dateBuf, dateTime.date, 2);
printNumber(dateBuf+3, dateTime.month, 2);
printNumber(dateBuf+6, dateTime.year, 2);
Экран выбора часового пояса честно слизан с Hulux’а
Model-View’им
При написания кода теперь нельзя забывать, что мы работам в многопоточной среде. Так, у меня есть поток, который обслуживает GPS: слушает Serial порт, побайтово парсит из него данные. Пакеты приходят раз в секунду. Библиотека знает, когда начинается следующий пакет и перед приемом обнуляет внутренние переменные. Когда пакет полностью принят выставляется флаг available. Данные приезжают на протяжении примерно за полсекунды (там байт 600 на скорости 9600). У нас есть еще полсекунды, чтобы их забрать, прежде чем начнется передача следующего пакета.
Второй поток занимается обслуживанием дисплея. Цикл отрисовки происходит каждые 100–120 мс. На каждой итерации программа берет актуальные данные из GPS и отрисовывает то, что сейчас хочет видеть пользователь — координаты, скорость, высоту или что нибудь еще. И тут возникает противоречие: поток дисплея хочет получать данные всегда, тогда как в библиотеке они доступны только полсекунды, а потом перезатираются.
Решение достаточно очевидное: скопировать данные к себе в промежуточный буфер. Естественно данные в этом буфере нужно защитить мутексом (mutex), иначе данные могут быть вычитаны некорректно. Но вот в чем проблема. Данные в потоке GPS появляются хоть и редко, но вычитать их можно быстро (там всего полторы сотни байт после парсинга), мутекс надолго блокировать не нужно. А вот функция рисования может работать довольно долго (до 20 мс). Блокировать мутекс на такое длительное время, в общем то, не сильно хорошо. Хотя и не смертельно, в этом конкретном проекте.
Можно, конечно, быстренько заблокировать мутекс, забрать данные в локальную переменную и отпустить мутекс. Но это чревато перерасходом памяти. Еще полторы сотни байт при 20 килобайтах это фигня, но лично меня напрягает сам факт тройной буферизации.
Буфер, кстати, пришлось объявить глобальной переменной ибо он очень большой и вызывает переполнение стека, если объявлять его в функции. На всякий случай потоку рисования выписал стека побольше.
NMEAGPS::satellite_view_t l_satellites[ NMEAGPS_MAX_SATELLITES ];
uint8_t l_sat_count;
void SatellitesScreen::drawScreen()
{
xSemaphoreTake(xGPSDataMutex, portMAX_DELAY);
memcpy(l_satellites, satellites, sizeof(l_satellites));
l_sat_count = sat_count;
xSemaphoreGive(xGPSDataMutex);
display.draw(....)
...
}
С мгновенными значениями, которые можно достать прямо из NMEA потока все просто — библиотека NeoGPS их вычитывает и раскладывает по переменным. Каждый скрин может просто прочитать соответствующую переменную (не забывая про синхронизацию, конечно) и отобразить ее на экране. Но вот с переменными, которые нужно вычислять так просто не получилось.
После долгого размышления я пришел к классической model-view схеме.
Объекты-наследники screen являются вьюшками — они отображают различные данные из модели, но сами данные не производят. Вся логика лежит в классе GPSDataModel. Он отвечает за хранение мгновенных GPS данных (пока не приедут новые данные из NeoGPS). Так же он отвечает за вычисление новых данных, таких как одометры или вертикальная скорость. И последнее, но не менее важное — этот класс сам занимается всей синхронизацией для своих данных.
const uint8 ODOMERTERS_COUNT = 3;
/**
* GPS data model. Encapsulates all the knowledge about various GPS related data in the device
*/
class GPSDataModel
{
public:
GPSDataModel();
void processNewGPSFix(const gps_fix & fix);
void processNewSatellitesData(NMEAGPS::satellite_view_t * sattelites, uint8_t count);
gps_fix getGPSFix() const;
GPSSatellitesData getSattelitesData() const;
float getVerticalSpeed() const;
int timeDifference() const;
// Odometers
GPSOdometerData getOdometerData(uint8 idx) const;
void resumeOdometer(uint8 idx);
void pauseOdometer(uint8 idx);
void resetOdometer(uint8 idx);
void resumeAllOdometers();
void pauseAllOdometers();
void resetAllOdometers();
private:
gps_fix cur_fix; /// most recent fix data
gps_fix prev_fix; /// previously set fix data
GPSSatellitesData sattelitesData; // Sattelites count and signal power
GPSOdometer * odometers[ODOMERTERS_COUNT];
bool odometerWasActive[ODOMERTERS_COUNT];
SemaphoreHandle_t xGPSDataMutex;
GPSDataModel( const GPSDataModel &c );
GPSDataModel& operator=( const GPSDataModel &c );
}; //GPSDataModel
/// A single instance of GPS data model
extern GPSDataModel gpsDataModel;
Т.к. класс модели отвечает за синхронизацию данных между потоками, то в нем живет мутекс, который регулирует доступ к внутренним полям класса. Мне было жутко неудобно (и некрасиво) пользоваться голыми xSemaphoreTake ()/xSemaphoreGive (), так что я нарисовал классический автозахватыватель (точнее даже автоотпускатель).
class MutexLocker
{
public:
MutexLocker(SemaphoreHandle_t mtx)
{
mutex = mtx;
xSemaphoreTake(mutex, portMAX_DELAY);
}
~MutexLocker()
{
xSemaphoreGive(mutex);
}
private:
SemaphoreHandle_t mutex;
};
Забрать текущее значение очень просто. Нужно просто вызвать функцию getGPSFix (), которая просто вернет копию данных.
gps_fix GPSDataModel::getGPSFix() const
{
MutexLocker lock(xGPSDataMutex);
return cur_fix;
}
Клиенту не нужно парится про блокировки и все такое. Просто забираем данные и рисуем как надо.
void SpeedScreen::drawScreen() const
{
// Get the gps fix data
gps_fix gpsFix = gpsDataModel.getGPSFix();
// Draw speed
...
printNumber(buf, gpsFix.speed_kph(), 4, true);
В классе модели хранится не только самые последние данные (cur_fix), но также предыдущее значение (prev_fix). Так что, вычисление вертикальной скорости становится тривиальной задачей.
float GPSDataModel::getVerticalSpeed() const
{
MutexLocker lock(xGPSDataMutex);
// Return NAN to indicate vertical speed not available
if(!cur_fix.valid.altitude || !prev_fix.valid.altitude)
return NAN;
return cur_fix.altitude() - prev_fix.altitude(); // Assuming that time difference between cur and prev fix is 1 second
}
С данными про спутники получилось весьма интересно. Данные про спутники живут в массиве структур NMEAGPS: satellite_view_t. Массив весит 150 байт и, как я уже писал, его необходимо несколько раз копировать. Не так, чтобы критично при наличии 20 кб оперативы, но все равно это трижды по 150 байт.
В конце концов я понял, что мне не нужны все данные, достаточно скопировать себе только то, что реально используется. В итоге родился вот такой класс.
class GPSSatellitesData
{
// Partial copy of NMEAGPS::satellite_view_t trimmed to used data
struct SatteliteData
{
uint8_t snr;
bool tracked;
};
SatteliteData satellitesData[SAT_ARRAY_SIZE];
uint8_t sat_count;
public:
GPSSatellitesData();
void parseSatellitesData(NMEAGPS::satellite_view_t * sattelites, uint8_t count);
uint8_t getSattelitesCount() const {return sat_count;}
uint8_t getSatteliteSNR(uint8_t sat) const {return satellitesData[sat].snr;}
bool isSatteliteTracked(uint8_t sat) const {return satellitesData[sat].tracked;}
};
Такой класс уже не так обидно лишний раз копировать — он занимает всего 40 байт.
Самой сложной частью схемы получился класс GPSOdometer. Как следует из названия он отвечает за все вычисления связанные с функциональностью одометра.
// This class represents a single odometer data with no logic around
class GPSOdometerData
{
// GPSOdometer and its data are basically a single object. The difference is only that data can be easily copied
// while GPS odometer object is not supposed to. Additionally access to Odometer object is protected with a mutex
// in the model object
// In order not to overcomplicte design I am allowing GPS Odometer to operate its data members directly.
friend class GPSOdometer;
bool active;
NeoGPS::Location_t startLocation;
NeoGPS::Location_t lastLocation;
float odometer;
int16 startAltitude;
int16 curAltitude;
clock_t startTime; ///! When odometer was turned on for the first time
clock_t sessionStartTime; ///! When odometer was resumed for the current session
clock_t totalTime; ///! Total time for the odometer (difference between now and startTime)
clock_t activeTime; ///! Duration of the current session (difference between now and sessionStartTime)
clock_t activeTimeAccumulator; ///! Sum of all active session duration (not including current one)
float maxSpeed;
public:
GPSOdometerData();
void reset();
// getters
bool isActive() const {return active;}
float getOdometerValue() const {return odometer;}
int16 getAltitudeDifference() const {return (curAltitude - startAltitude) / 100.;} // altitude is in cm
clock_t getTotalTime() const {return totalTime;}
clock_t getActiveTime() const {return activeTimeAccumulator + activeTime;}
float getMaxSpeed() const {return maxSpeed;}
float getAvgSpeed() const;
float getDirectDistance() const;
};
// This is an active odometer object that operates on its odometer data
class GPSOdometer
{
GPSOdometerData data;
public:
GPSOdometer();
// odometer control
void processNewFix(const gps_fix & fix);
void startOdometer();
void pauseOdometer();
void resetOdometer();
// Some data getters
GPSOdometerData getData() {return data;}
bool isActive() const {return data.isActive();}
}; //GPSOdometer
Сложность вот в чем. Объект gps_fix, который поступает нам от GPS, может содержать какие то данные, а какие то может и нет. Например координата приедет, а высота нет. А в следующем фиксе может быть наоборот. Поэтому просто сохранять gps_fix не выйдет. Нужно каждый раз смотреть что доступно в новом фиксе, а что нет. Поэтому пришлось городить весьма сложный алгоритм, запоминать координаты, высоты и временные отметки по отдельности.
void GPSOdometer::processNewFix(const gps_fix & fix)
{
Serial.print("GPSOdometer: Processing new fix ");
Serial.println((int32)this);
if(data.active)
{
Serial.println("Active odometer: Processing new fix");
// Fill starting position if needed
if(fix.valid.location && !isValid(data.startLocation))
data.startLocation = fix.location;
// Fill starting altitude if neede
if(fix.valid.altitude && !data.startAltitude) // I know altitude can be zero, but real zero cm altutude would be very rare condition. Hope this is not a big deal
data.startAltitude = fix.altitude_cm();
// Fill starting times if needed
if(fix.valid.time)
{
if(!data.startTime)
data.startTime = fix.dateTime;
if(!data.sessionStartTime)
data.sessionStartTime = fix.dateTime;
}
// Increment the odometer
if(fix.valid.location)
{
// but only if previous location is really valid
if(isValid(data.lastLocation))
data.odometer += NeoGPS::Location_t::DistanceKm(fix.location, data.lastLocation);
// In any case store current (valid) fix
data.lastLocation = fix.location;
}
// Store current altitude
if(fix.valid.altitude)
data.curAltitude = fix.altitude_cm();
// update active time values
if(fix.valid.time)
data.activeTime = fix.dateTime - data.sessionStartTime;
// update max speed value
if(fix.valid.speed && fix.speed_kph() > data.maxSpeed)
data.maxSpeed = fix.speed_kph();
}
//Total time can be updated regardless of active state
if(fix.valid.time && data.startTime)
data.totalTime = fix.dateTime - data.startTime;
}
В этом месте у меня резко увеличился размер флеша — почти на 10 кб. В проект приползла куча математического кода — синусы, косинусы, тангенсы, квадратные корни и все такое прочее. Оказалось, что ноги растут из функции NeoGPS: Location_t: DistanceKm () — все это используется в вычислении расстояния на основе координат. Скрипя зубами пришлось согласится, но задумался о контроллере на Cortex M4 — там это хардварно должно вычисляться.
void GPSOdometer::startOdometer()
{
data.active = true;
// Reset session values
data.sessionStartTime = 0;
data.activeTime = 0;
}
void GPSOdometer::pauseOdometer()
{
data.active = false;
data.activeTimeAccumulator += data.activeTime;
data.activeTime = 0;
}
void GPSOdometer::resetOdometer()
{
data.reset();
}
Обратите внимание, что в классе одометра нет никакой синхронизации. Это потому, что вся синхронизация происходит в классе GPSDataModel. Я просто не хотел городить по мутексу в каждом объекте. Но из-за этого мне пришлось усложнить сам класс одометра и разделить на 2 класса: объект с данными (GPSOdometerData) может копироваться по запросу клиентов, тогда как объект управления (GPSOdometer) создаются один раз на каждый одометр. Из-за этого также пришлось один класс сделать friend«ом другому. Возможно я пересмотрю этот дизайн в будущем.
Так выглядит основной экран одометра. Символ точки в шрифт еще не добавил — должно показывать 0.42