Ещё один Хаброметр
В рамках проекта контроллера сервоприводов MC50 постоянно пишутся тестовые прошивки. В результате одной из проб получился Хаброметр — устройство навеянное вот этими статьями HabrScore, HabraTab. Наш Хаброметр периодически парсит страницу Хабра, извлекает из неё данные пользователя и выводит на экран, одновременно ведёт хронологическую запись полученных данных в файл.csv и выдаёт голосовые сообщения о величине кармы, когда она изменяется.
Тестируем возможности фреймворка SSP в связке с Azure RTOS. Сама по себе задача Хаброметра является отличным способом проверить аппаратную платформу в реальных условиях интернета. Платформой является контроллер сервоприводов MC50, о котором статьи здесь:
Проблемы парсинга HTML страниц Хабра
Во‑первых, сервер Хабра отдаёт страницы с использованием протокола TLS, т. е. зашифрованными и с цепочкой подтверждающих сертификатов. Это значит мы должны иметь в своём стеке TCP поддержку TLS не менее 1.2. Azure RTOS имеет такую возможность, но не имеет подходящих корневых сертификатов для валидации сервера Хабра. Поскольку в Azure RTOS честный TLS, то обойти валидацию нельзя (например, просто проигнорировав сертификаты). Нужно в прошивку вставить корневой сертификат подходящий Хабру. Взять из браузера сертификат не получится. Сервет Хабра браузеру отдаёт другой сертификат, чем устройству. Поэтому с помощь Wireshark извлекаем сертификат из одной из неудачных сессий связи с устройством. Теперь этот сертификат лежит в файле Habr_CA_certificate.h и с ним происходит сравнение при каждом подсоединении по HTTPS.
Во‑вторых, чтобы быстро передавать страницы браузеру сервер Хабра их сжимает. В сжатом виде страница имеет размер около 26 Кбайт. К счастью если не объявить алгоритм сжатия, то сервер возвращает несжатые страницы. Тогда размер страниц будет в пределах 240–270 Кбайт для перечня моих статей. Размеры страницы все время колеблется в пределах пары десятков килобайт между последовательными скачиваниями. Сжатие применить не можем, поскольку не хватит динамического ОЗУ для промежуточных буферов. Без сжатия страница скачивается за время около 3 сек. при хорошем интернете.
Во‑третьих, нам нужно как‑то искать нужные цифры на странице. Цифры окружены тэгами, но мы не можем портировать полнофункциональный парсер HTML. Опять из‑за дефицита ОЗУ. Поэтому применяем простой поиск текстовых маркеров предваряющих нужные цифры. И к счастью такие маркеры оказались довольно короткими и уникальными. Правда они могут поменяться при изменении стиля, а стиль меняется когда элементы, допустим, выделены пользователем. Но пока все было хорошо. Я определил такие маркеры:
// Перечень маркеров сопровождающих искомые числовые значения для Хаброметра
#define KARMA_MARK "tm-karma__votes tm-karma__votes_positive\">"
#define RATING_MARK "tm-votes-lever__score-counter_rating\">"
#define SUBSCRIPT_MARK "tm-navigation-dropdown__option-count\">"
#define VOTES_MARK "tm-votes-meter__value_appearance-article tm-votes-meter__value_rating\">"
#define VIEWS_MARK "<span class=\"tm-icon-counter__value\">"
#define BOOKMARKS_MARK "bookmarks-button__counter\">"
#define LEFT_MARK "<"
Для поиска маркеров в потоке пакетов написал простой автомат состояний в виде функции Find_habr_marked_number. Функция находится в файле String_utils.c.
Функция Find_habr_marked_number выглядит так:
/*-----------------------------------------------------------------------------------------------------
Поиск числа в потоке HTML текста HABR заключенного между маркерами, заданными в управляющей структуре
\param block - входной блок данных
\param block_size - размер фходного блока данных в байтах
\param fcbl - управляющая структура автомата
\return uint32_t - возвращает 1, когда число найдено, иначе 0
-----------------------------------------------------------------------------------------------------*/
uint32_t Find_habr_marked_number(const uint8_t *block, uint32_t block_size, T_marked_num_finder *fnd)
{
uint8_t b;
if (fnd->step == 0)
{
fnd->cnt = 0;
fnd->fragment_sz = 0;
fnd->step = 1;
}
else if (fnd->step < 0)
{
return 0;
}
else if (fnd->step > 2)
{
return 1;
}
while (block_size > 0)
{
b =*block;
switch (fnd->step)
{
case 1:
// Ищем совпадение с левым текстовым маркером
if (b == fnd->left_mark[fnd->cnt])
{
fnd->cnt++;
if (fnd->cnt == fnd->left_mark_sz)
{
fnd->step++;
fnd->cnt = 0;
}
}
else
{
fnd->cnt = 0;
}
break;
case 2:
// Ищем совпадение с проавым текстовым маркером
if (b == fnd->right_mark[fnd->cnt])
{
fnd->cnt++;
if (fnd->cnt == fnd->right_mark_sz)
{
fnd->step = 0;
fnd->cnt = 0;
if ((fnd->fragment_sz - fnd->cnt) > 0)
{
float scale = 1.0f;
fnd->fragment_sz = fnd->fragment_sz - fnd->cnt;
fnd->fragment[fnd->fragment_sz] = 0;
// Очищаем найденный текст от служебных символов. Заменяем их на пробел
for (uint32_t i=0; i < fnd->fragment_sz; i++)
{
if (fnd->fragment[i] == 'K') scale = 1000.0f;
if ((isalnum(fnd->fragment[i]) == 0) && (fnd->fragment[i] != '.')) fnd->fragment[i] = ' ';
}
// Конвертируем строку в число с плавающей точкой
fnd->number = (float)atof(fnd->fragment)*(float)scale;
fnd->step = 3;
return 1;
}
}
}
else
{
fnd->cnt = 0;
}
// Накапливаем строку в которой может содержаться искомое число
fnd->fragment[fnd->fragment_sz] = b;
fnd->fragment_sz++;
if ((fnd->fragment_sz - fnd->cnt) >= MAX_MARKER_SIZE)
{
// Если строка достигла предельного размера, то отменяем ее и начинаем весь поиск заново.
fnd->step = 0;
}
break;
}
block++;
block_size--;
}
return 0;
}
Вся задача находится в файле Habr_Karma_thread.c. В настройках контроллера можно задавать имя пользователя и периодичность парсинга. На SD карте хранятся файлы с речевыми сообщениями. В разных директориях сообщения на разных языках. Аудиопроигрыватель поддерживает монофонические.wav файлы с произвольной частотой дискретизации.
Как работает задача вывода на дисплей.
Вывод на дисплей осуществляется с помощью API GUIX. Если знать как работает GUIX, то все на удивление просто. Начинаем с того, что рисуем макет экрана в редакторе GUIX Studio. Вот такой:
Не забываем проставить имена двух обработчиков событий: Habrometer_draw_callback и Habrometer_event_callback. Это ключевые обработчики, которые надо заполнить своим кодом.
Весь код вывода на дисплей
#include "MC50.h"
#include "MC50_specifications.h"
#include "MC50_resources.h"
#include "Habr_Karma_thread.h"
GX_WINDOW *habrometr_screen;
#define ID_VAL_USER 0
#define ID_VAL_VOICES 1
#define ID_VAL_VIEWS 2
#define ID_VAL_SUBSCRAIBERS 3
#define ID_VAL_RATING 4
#define ID_VAL_KARMA 5
#define ID_VAL_DATE_TIME 6
GX_PROMPT* prmts[7] =
{
{&window_habrometr.window_habrometr_Val_user } ,
{&window_habrometr.window_habrometr_Val_voices } ,
{&window_habrometr.window_habrometr_Val_views } ,
{&window_habrometr.window_habrometr_Val_subscraibers } ,
{&window_habrometr.window_habrometr_Val_rating } ,
{&window_habrometr.window_habrometr_Val_karma } ,
{&window_habrometr.window_habrometr_Val_date_time }
};
/*-----------------------------------------------------------------------------------------------------
\param void
-----------------------------------------------------------------------------------------------------*/
void Init_habrometr_screen(void)
{
if (habrometr_screen == 0)
{
gx_studio_named_widget_create("window_habrometr" , 0, (GX_WIDGET **)&habrometr_screen);
}
gx_widget_attach((GX_WIDGET *)root, (GX_WIDGET *)habrometr_screen);
}
/*-----------------------------------------------------------------------------------------------------
\param void
-----------------------------------------------------------------------------------------------------*/
static void _Show_habrometr(void)
{
GUI_print_to_prompt(ID_VAL_USER ,prmts,"%s" , wvar.habr_user_name);
GUI_print_to_prompt(ID_VAL_VOICES ,prmts,"%.0f", (double)habr_results.number_of_votes);
GUI_print_to_prompt(ID_VAL_VIEWS ,prmts,"%.0f", (double)habr_results.number_of_views);
GUI_print_to_prompt(ID_VAL_SUBSCRAIBERS ,prmts,"%.0f", (double)habr_results.number_of_subscribers);
GUI_print_to_prompt(ID_VAL_RATING ,prmts,"%.1f", (double)habr_results.rating);
GUI_print_to_prompt(ID_VAL_KARMA ,prmts,"%.0f", (double)habr_results.karma);
rtc_time_t curr_time;
rtc_cbl.p_api->calendarTimeGet(rtc_cbl.p_ctrl,&curr_time);
curr_time.tm_mon++;
GUI_print_to_prompt(ID_VAL_DATE_TIME ,prmts,"%04d.%02d.%02d %02d:%02d:%02d", curr_time.tm_year+1900, curr_time.tm_mon, curr_time.tm_mday, curr_time.tm_hour, curr_time.tm_min, curr_time.tm_sec);
}
/*-----------------------------------------------------------------------------------------------------
\param void
-----------------------------------------------------------------------------------------------------*/
static uint32_t _Encoder_processing(void)
{
if (Get_switch_press_signal())
{
gx_system_timer_stop((GX_WIDGET *)habrometr_screen, ENCODER_PROC_TIMER_ID);
gx_widget_detach((GX_WIDGET *)habrometr_screen);
Init_menu_screen();
return 0;
}
return 1;
}
/*-----------------------------------------------------------------------------------------------------
\param window
-----------------------------------------------------------------------------------------------------*/
VOID Habrometer_draw_callback(GX_WINDOW *window)
{
gx_window_draw(window);
gx_widget_children_draw(window);
}
/*-----------------------------------------------------------------------------------------------------
\param window
\param event_ptr
\return UINT
-----------------------------------------------------------------------------------------------------*/
UINT Habrometer_event_callback(GX_WINDOW *window, GX_EVENT *event_ptr)
{
UINT status;
switch (event_ptr->gx_event_type)
{
case GX_EVENT_SHOW:
_Show_habrometr();
gx_system_timer_start((GX_WIDGET *)window, ENCODER_PROC_TIMER_ID, ENCODER_PROC_INTIT_TICKS, ENCODER_PROC_PERIOD_TICKS);
status = gx_window_event_process(window, event_ptr);
break;
case GX_EVENT_TIMER:
if (event_ptr->gx_event_payload.gx_event_timer_id == ENCODER_PROC_TIMER_ID)
{
if (_Encoder_processing()) _Show_habrometr();
}
break;
default:
status = gx_window_event_process(window, event_ptr);
return status;
}
return 0;
}
Редактор сгенерирует файлы MC50_resources.c, MC50_resources.h и MC50_specifications.c, MC50_specifications.h. В них будут все нужные ресурсы и определения всех экранных объектов. Эти файлы надо просто подключить к своему проекту.
Когда мы инициализируем GUIX вызовом функции gx_system_initialize, то в недрах GUIX создаётся задача GUIX System Thread. Перед тем следует проверить в конфигурационном хедере GUI какой у задачи приоритет и стек. Эта задача занимается вызовом всех событий и прорисовкой. Мы в свою очередь перехватываем события GUI в функции Habrometer_event_callback и в ней организуем таймер, чтобы периодически вызывать эту же функцию для динамического вывода показателей и тут же обрабатываем сигналы от задачи ручного энкодера (но не сами сигналы энкодера, им занимается отдельная задача) чтобы менять режимы экрана. Функция Habrometer_draw_callback ничего интересного не делает в нашем случае, но в ней можно прорисовать некие особенные элементы на экране, которые не рисует сама GUI. Виджеты в GUI имеют иерархические связи наследования. И чтобы виджет начал или перестал прорисовываться его надо присоединить (gx_widget_attach) или отсоединить (gx_widget_detach) от родительского окна. Окна тоже виджеты и могут содержать другие окна. Остальную информацию по применению можно найти в демонстрационных примерах GUIX в репозитарии.
Структура задач
Состояние запущенных задач удобно наблюдать в отладчике С‑SPY в IDE IAR. Для ядра ThreadX Azure RTOS там есть специальный add‑on.
Не все задачи требуются для Хаброметра, но это тестовый проект поэтому тестируем все уже реализованные задачи. При планировании задач очень важно следить за их приоритетами. У нас вытесняющая многозадачность. Задача с более высоким приоритетом (параметр Priority c меньшим значением) может прервать выполнение задачи с более низким приоритетом. Некоторые задачи должны выполняться с жёстким риалтаймом и должны иметь высший приоритет. Но многие задачи используют сервисы других задач во время своей работы. Например всеми используется задача логера. Многие используют задачу Net. Файловая система используется одновременно многими задачами, и при неудачных обстоятельствах может случиться deadlock. Приоритеты задач — та вещь, которая постоянно перетасовывается в течении разработки и тесты призваны подтвердить правильное назначение приоритетов.
Расход памяти
Нет ничего более дефицитного в микроконтроллере, чем объем внутреннего ОЗУ. Поэтому к нему всегда повышенное внимание. Одна из задач тестов — определить размеры стеков и свести их к минимуму, чтобы освободить дополнительное место в ОЗУ.
Нам нужно свободное ОЗУ для организации в нем динамической памяти. В нашем случае необходимо не менее 128 Кбайт динамической памяти. Такой размер в основном определяется движком самовосстановления файловой системы. Как писалось ранее, у нас применяется файловая система устойчивая к сбоям со специальным механизмом журналирования. При каждом старте программы происходит вызов функции fx_media_check, которая проверяет и восстанавливает файловую систему в случаях сбоев. Но эта функция требует не менее 128 Кбайт ОЗУ для своей работы. Также значительные объёмы ОЗУ требуют компрессоры. Чем больше памяти даём компрессору, тем лучше он сжимает. Оптимизированным по потреблению памяти компрессорам нужно около 40 Кбайт динамической памяти. Динамическая память нужна и JSON парсеру, там нужно около 50 Кбайт. Есть и другие потребители динамической памяти. К счастью не вся эта память нужна одновременно.
Вся прошивка использует такой объем памяти:
Области readonly code memory и readonly data memory - это то, что размещается во внутренней Flash микроконтроллера. readwrite - размещается во внутреннем ОЗУ микроконтроллера.
Динамической памяти остаётся разница между полным размером ОЗУ 655360 байт и размером readwrite памяти, т.е. 149213 байт.
Может показаться, что в проекте большую часть внутреннего ОЗУ занимают стеки задач. Но таблица говорит об обратном. Стеки задач не играют решающую роль в расходе оперативной памяти. Они занимают чуть более 7% и могут быть сокращены до 5%
График счётчика количества просмотров
Чтобы получить график какого либо показателя из Хаброметра нужно скачать файл <user_name>.csv с встроенного FTP сервера. FTP сервер контроллера MC50 в локальной сети будет доступен по имени MC50, но это можно изменить в настройках.
Карма меняется редко. Смотреть её на графике не интересно. Рейтинг, как можно понять, увеличивается на 1 при каждом плюсе за статью, потом, если долго не было плюсов начинает снижаться. Это тоже предсказуемо и не интересно.
А вот график количества просмотров интересен, его и посмотрим. Файл накопленных результатов <user_name>.csv открывается в Excel почти как нативный. Все сразу расставляется по своим столбцам. Нужно только выделить интересующие столбцы и нажать кнопку создания графика.
Вот график просмотров этой статьи.
Ступенчатость графика количества просмотров обусловлена тем, что после 1000 просмотров Хабр начинает округлять показатель до сотен.
В любом случае очевидно решающее значение первых нескольких дней для судьбы статьи. Можно сделать робкий вывод по поводу времени публикации статьи. Оно должно быть ранним утром по восточноевропейскому летнему времени. Чтобы узнать более точно надо проводить дополнительные замеры.
Немного тюнинга
Как всегда не обошлось без тюнинга платы. На этот раз произошла ошибка в определении функции пина на аудио‑усилителе. IS31AP4991A. Такого рода усилителей с точно такой же распиновкой существует несколько аналогов. К примеру TS4871IST. Но оказывается у них разный уровень активного сигнала STDBY. У IS31AP4991A чип выключается нулём, у TS4871IST единицей.
В итоге
В течение испытаний контроллер без сбоев работал несколько суток делая выборки каждые 30 сек. По дороге был даже словлен баг маршрутизатора, который через некоторое время перестал определять по DNS адрес Хабра. В таких случаях надо прописывать в дивайс дополнительный DNS кроме того, что он получает по DHCP.
Весь проект находится здесь.