Несколько месяцев назад на хабре появилась статья «Реализация многоуровневого меню для Arduino с дисплеем». «Но, погодите, — подумал я. — Я написал такое меню еще шесть лет назад»!

В далеком 2009 году, я написал первый проект на базе микроконтроллера и дисплея под названием «Автомат управления освещением», для которого потребовалось создать такую оболочку меню, в которую влезет тысяча конфигов, а то и более. Проект был успешно рожден, компилируется и способен работать до сих пор, а оболочка менюОС пошла кочевать из проекта в проект, используя лучшие практики Ущербно-Ориентированного программирования. «Хватит это терпеть» сказал я, и переписал код.

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

Требования и возможности менюОС


Для начала определимся с требованиями, которые мы предъявляем к меню:
  1. простота использования, кнопки влево-вправо, вверх-вниз, назад-вперед.;
  2. древовидная структура любой адекватной глубины (до 256);
  3. общее количество пунктов меню, которого хватит всем (10^616);
  4. редактирование настроек;
  5. запуск программ.
  6. простенький встроенный диспетчер задач.

А еще, необходимо чтобы все это как можно меньше весило, было неприхотливо к ресурсам и запускалось на любой платформе(пока есть для AVR, работает с GLCD и текстовым LCD).
Теоретически, с соответствующими драйверами, данное менюОС можно просто взять и подключить к RTOS.

Файловая структура


В качестве примера, будем разбирать следующую структуру меню(слева номер пункта):
0 Корень/
   1 - Папка 1/ - папка с файлами
       3 -- Программа 1
       4 -- Программа 2
       5 -- Папка 3/  - папка с множеством копий программы. Положение курсора будет являться параметром запуска
           6 --- Программа 3.1
           6 --- Программа 3.2
           6 --- Программа 3.3
           6 --- хххххх
           6 --- Программа 3.64
   2 - Папка 2/ - папка  с конфигами
       7 -- Булев конфиг 1
       8 -- Числовой конфиг 2
       9 -- Числовой конфиг 3
      10 --  Программа Дата/время

Главным догматом менюОС является «Все есть файл». Да будет так.
У каждого файла есть тип, название, родительская папка, прочие параметры
Опишем структурой:
struct filedata{
	uint8_t type;
	uint8_t parent;
	uint8_t mode1;//параметр 1
	uint8_t mode2;//параметр 2
	char name[20];
};

Для каждого файла определим 4 байта в массиве fileData:
  1. type,
  2. parent, он не очень нужен, так как вся информация есть в хлебных крошках, но остался как legacy
  3. mode1, два параметра, специфичных для каждого типа файла
  4. mode2


type == T_FOLDER

Основным файлом является папка. Она и позволяет создать древовидную структуру всего меню.
Самая главная здесь — корневая папка под номером нуль. Что бы не произошло, в итоге мы вернемся в нее.
Параметрами папки являются
mode1 = стартовый номер дочернего файла,
mode2 = количество файлов в ней.

В корневой папке 0 лежат файлы 1 и 2, всего 2 штуки.
Опишем ее так:
T_FOLDER, 0, 1, 2,


type == T_DFOLDER

В Папке 3 лежит несколько копий одной и той же программы, однако с разными ключами запуска.
Например, в автомате управления освещением имеется возможность установить до 64 суточных программ, с 16 интервалами в каждой. Если описывать каждый пункт, потребуется 1024 файла. На практике достаточно двух. А хлебные крошки скормим программе в виде параметров.
mode1 = номер дочернего файла, копии которого будем плодить
mode2 = количество копий файла.

Нехитрая математика подсказывает нам, что если все 256 файлов будут динамическими папками с максимальным числом копий, общее число пунктов меню в системе составит 256^256 = 3.2 x 10^616. Этого ТОЧНО хватит на любой адекватный и не очень случай.

type == T_APP

Приложение. Его задача — прописаться в диспетчере задач (встроенном или внешнем), перехватить управление кнопками и править.
mode1 = id запускаемого приложения.


type == T_CONF

Конфиг-файл, ради которого и затеян весь сыр-бор. Позволяет устанавливать булево или числовое значение какого-либо параметра. Работает с int16_t.
mode1 = id конфига

У конфига есть свой массив configsLimit, где на каждый конфиг приходится три int16_t числа конфигурации:
  1. Cell ID — Стартовый номер ячейки памяти для хранения данных. Все данные занимают два байта.
  2. Minimum — минимальное значение данных
  3. Maximum — максимальное значение данных.

Например, в ячейку 2 можно записать число от -100 до 150, тогда строка примет вид:
2, -100, 150, 


type == S_CONF

Интересный(но оставшийся пока только в старом коде) конфиг, работает в связке с T_SFOLDER
mode1 = id конфига


type == T_SFOLDER

Особый вид папки вынесен ближе к конфигу, так как является одной из его разновидностей.
Представьте себе, у вас в системе зашита возможность работы по RS-485 по протоколам A,B или C. Помещаем в папку кучку файлов вида S_CONF и выбираем из них необходимый. Более того, когда мы зайдем в папку вновь, курсор подсветит активный вариант.
mode1, mode2 аналогичны для T_FOLDER. Дочерними файлами являются только T_SCONF

Результаты рефакторинга


Я не ставил перед собой задачу пересмотра архитектуры, во многих местах я даже оставил логику работы как есть. Есть весьма забавные костыли.
Основная задача — перебрать систему так, чтобы ее использование в новых проектах было простым. В итоге:
  1. Выделил работу с аппаратной частью как минимум в отдельные функции в отдельном файле. В HWI вошли:
  2. Переписаны модули под классы. Спрятано в private все что только можно, унифицирован внешний вид, Фишка с классами и более-менее унифицированным интерфейсом потом пригодится.
  3. «Добавлен» интерфейс для работы с RTOS. Вернее, штатный диспетчер задач довольно просто заменить на любой другой.
  4. Банально прибрался в коде, сделал его более понятнее, убрал магические числа, улучшил интерфейс. Теперь его не стыдно показать.

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

Создание своего проекта


Настройка проекта включает в себя следующие пункты:

Создание файлов

Создадим массивы по ранее рассмотренной структуре
//массив структуры
static const uint8_t fileStruct[FILENUMB*FILEREW] PROGMEM =
{
	T_FOLDER, 0, 1, 2,				//0
		T_FOLDER, 0, 3, 3,			//1
		T_FOLDER, 0, 7, 4,			//2
			T_APP,	1, 1, 0,		//3
			T_APP,	1, 2, 0,		//4
			T_DFOLDER, 1, 6, 66,		//5
				T_APP,	5, 2, 0,		//6			
			T_CONF,	2, 0, 0,		//7
			T_CONF,	2, 1, 0,		//8
			T_CONF,	2, 2, 0,		//9
			T_APP, 2, 3, 0			//10
		
	
};

//Массив названий
static PROGMEM const char file_0[] = "Root";
static PROGMEM const char file_1[] = "Folder 1";
static PROGMEM const char file_2[] = "Folder 2";
static PROGMEM const char file_3[] = "App 1";
static PROGMEM const char file_4[] = "App 2";
static PROGMEM const char file_5[] = "Dyn Folder";
static PROGMEM const char file_6[] = "App";
static PROGMEM const char file_7[] = "config 0";
static PROGMEM const char file_8[] = "config 1";
static PROGMEM const char file_9[] = "config 2";
static PROGMEM const char file_10[] = "Date and Time";

PROGMEM static const char *fileNames[]  = {
	file_0,  file_1,  file_2,  file_3,  file_4,  file_5,  file_6,  file_7,  file_8,
	file_9, file_10
};

Создадим массив для конфигов:
//number of cell(step by 2), minimal value, maximum value
static const PROGMEM int16_t configsLimit[] = {
	0,0,0,// config  0:  0 + 0 дадут булев конфиг
	2,-8099,8096,//config 1
	4,1,48,//config	2
};


Настройка кнопок

Я предпочитаю подключать кнопки с замыканием на землю и подтягивающим резистором к питанию, который всегда в наличии в МК.

В файле hw/hwdef.h укажем названия регистров и расположение кнопок:
 #define BUTTONSDDR DDRB
 #define BUTTONSPORT PORTB
 #define BUTTONSPIN PINB
 #define BUTTONSMASK 0x1F
 #define BSLOTS 5
 
 /**Button mask*/
 enum{
	BUTTONRETURN = 0x01,
	BUTTONLEFT = 0x02,
	BUTTONRIGHT = 0x10,
	BUTTONUP = 0x08,
	BUTTONDOWN = 0x04
 };


Настройка дисплея

Сейчас проект тащит за собой библиотеку GLCDv3, что не есть хорошо. Исторически так сложилось.
Ссылка на google-code — https://code.google.com/p/glcd-arduino

Создание приложения

Рассмотрим пример приложения, использующий базовые функции меню.
menuos/app/sampleapp.cpp

Создадим класс со следующей структурой:
#ifndef __SAMPLEAPP_H__
#define __SAMPLEAPP_H__

#include "hw/hwi.h"

#include "menuos/MTask.h"
#include "menuos/buttons.h"

class sampleapp
{
//variables
public:
	uint8_t  Setup(uint8_t argc, uint8_t *argv);//запуск приложения. В качестве параметров - текущий уровень и массив хлебных крошек
	uint8_t  ButtonsLogic(uint8_t button);//обработчик кнопок
	uint8_t TaskLogic(void);//обработчик таймера
protected:
private:
	uint8_t tick;
	void Return();//возврат в главное меню

//functions
public:
	sampleapp();
	~sampleapp();
protected:
private:

}; //sampleapp
extern sampleapp SampleApp;

//Сишные <s>костыли</s>обертки для обработчика кнопок и диспетчера
void SampleAppButtonsHandler(uint8_t button);
void SampleAppTaskHandler();

#endif //__SAMPLEAPP_H__

И набросаем основные функции:
uint8_t sampleapp::Setup(uint8_t argc, uint8_t *argv)
{
	tick = 0;
        //пропишем себя в системных модулях
	Buttons.Add(SampleAppButtonsHandler);//add button handler
	Task.Add(1, SampleAppTaskHandler, 1000);//add task ha
	GLCD.ClearScreen();//очистим экран 
        //и на самом видном месте напишем 
	GLCD.CursorTo((HwDispGetStringsLength()-11)/2, HwDispGetStringsNumb()/2);
	GLCD.Puts("Hello Habr");	
	return 0;
}


Обертки:
void SampleAppButtonsHandler(uint8_t button){
	SampleApp.ButtonsLogic(button);
}

void SampleAppTaskHandler(){
	SampleApp.TaskLogic();
}


Обработчик кнопок:
uint8_t sampleapp::ButtonsLogic(uint8_t button){
	switch (button){
		case BUTTONLEFT:
		
		break;
		case BUTTONRIGHT:
	
		break;
		case BUTTONRETURN:
		Return();
		break;
		case BUTTONUP:
		
		break;
		case BUTTONDOWN:

		break;
		default:
		
		break;
		
	}
	return 0;
}

И функция, которая будет вызываться каждую секунду:
uint8_t sampleapp::TaskLogic(void){
	GLCD.CursorTo((HwDispGetStringsLength()-11)/2, HwDispGetStringsNumb()/2+1);
	GLCD.PrintNumber(tick++);	
}

Теперь в menu.cpp пропишем, что по номеру 2 будет вызываться наша программа:
void MMenu::AppStart(void){
	if (file.mode2 != BACKGROUND){
		Task.Add(MENUSLOT, MenuAppStop, 10);//100 ms update
		Task.ActiveApp = 1;//app should release AtiveApp to zero itself
	}
	switch (file.mode1){//AppNumber
		case 2:
			SampleApp.Setup(level, brCrumbs);
		break;
		case 3:
			Clock.Setup(level, brCrumbs);
		break;
		default:
			Task.ActiveApp = 0;		
		break;
	}
}

Соберем проект и посмотрим, что у нас получилось:


То же самое для визуалов


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


Ссылки и репозитории


Проект собран в среде программирования Atmel Studio, но настанет тот день и он будет форкнут и под Eclipse. Актуальная версия проекта доступна в любом репозитории(Резервирование).
  1. Репозиторий на GitHub: https://github.com/radiolok/menuosv1
  2. Репозиторий на Bitbucket: https://bitbucket.org/radiolok/menuosv1
  3. GLCDv3: https://code.google.com/p/glcd-arduino/
  4. openLCD:https://bitbucket.org/bperrybap/openglcd/

Комментарии (20)


  1. Zibx
    10.05.2015 03:05
    +6

    Какой сильный флешбек во времена школы и квикбейсика.


  1. FisHlaBsoMAN
    10.05.2015 08:55

    А я своё так и не дописал до конечного состояния. Решил что я в тупик зашёл по ресурсам из за использования динамической памяти для всего этого, да и ардуинку уже подарил другому человеку в виде midi клавиатуры. Поделиться своим велосипедным меню? Отлаживать и демонстрировать буду на эмуляторе.
    Вот одна из ранних-ранних стадий, еще без дисплея, но с логикой пунктов:

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


    1. radiolok Автор
      10.05.2015 09:53

      Постраничное отображение с контролем границ.


    1. FisHlaBsoMAN
      10.05.2015 21:24

      Вот вам на растерзание. Требует порт vector. Перекатался я на велосипеде сегодня… Извините сил нет объяснять. Как только буду в состоянии расскажу подробнее. Я уже и не помню рабочее ли оно.


      1. FisHlaBsoMAN
        11.05.2015 10:39

        Ссылку потерял bitbucket.org/fishlabsoman/menuarduino


      1. FisHlaBsoMAN
        11.05.2015 16:48

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

        Куски кода:

        Скрытый текст
        typedef byte ( * tCallback)(byte, byte, char * );
        
        //prototypes NEED FOR CALLBACK
        byte callback_StartTimer(byte id, byte parent, char * name);
        //struct define typical menu item
        struct menuItem {
        	byte id;
        	char * name;
        	byte parent; //id parent
        	tCallback callback; //callback function
        	bool haveChilds;//using for optimizations
        };
        vector<menuItem> items;//vector of menu items
        
        int freeRam () { //display free ram memory
        	extern int __heap_start, * __brkval;
        	int v;
        	return (int) & v - (__brkval == 0 ? (int) & __heap_start : (int) __brkval);
        }
        
        void setup() {
                menuItem root;
        	{
        		root.id       = 10;
        		root.name     = "Set octave";
        		root.parent   = CONST_ROOT_ITEM;
        		root.callback = & callback_SetOcave;
        		items.push_back(root);
        
        		root.id       = 12;
        		root.name     = "Setup";
        		root.parent   = CONST_ROOT_ITEM;
        		root.callback = NULL;
        		items.push_back(root);
        
        		{
        			root.id       = 24;
        			root.name     = "Time";
        			root.parent   = 12;
        			root.callback = & callback_resetAllKeys;
        			items.push_back(root);
        		}
        	}
                Serial.println("Free ram: " + String(freeRam()));
        }
        
        //call callback of menu item
        items[i].callback(id, parent, name);
        
        byte callback_StartTimer(byte id, byte parent, char * name) { //return true if need reprint menu
        	//Serial.println("Callbacked function callback_StartTimer by " + String(name));
        	return false;
        }
        
        //print menu recursively. return need for checking empty menu
        bool printMenuRecurcive(vector<menuItem> & items, byte parent, byte level) {
        	if (level > CONST_MAX_LEVEL) return false;
        	bool haveChilds = false;//flag reports if have items
        	for (byte i = 0; i < items.size(); i++) {
        
        		if (parent == items[i].parent) {
        			haveChilds = true;//yay! his have items
        			String indent = "";
        
        			for (int j = 0; j < level; j++) {
        				indent += "  ";
        			}
        
        			if (items[i].haveChilds) {
        				Serial.println(indent + String(items[i].name) + ">");
        			} else {
        				Serial.println(indent + String(items[i].name));
        			}
        			printMenuRecurcive(items, items[i].id, level + 1); //print next level
        
        		}//end if parent
        	}//end for
        	return haveChilds;
        }//end func
        


    1. kumbr_87
      10.05.2015 23:25
      +1

      Можно реализовать это следующим образом — есть несколько переменных, одна хранит максимальное количество пунктов помещающихся на экране (max_draw_num), вторая хранит номер пункта который отображается на экране первым (first_draw_num), третья хранит номер пункта выделенного на экране (select_draw_num).
      Т.е. например всего на экране помещается четыре строки, при этом меню содержит 8 элементов, в текущий момент времени отображаются пункты с 3 по 6, а выделен пункт номер 4.
      Тогда:
      max_draw_num=4 (количество элементов на экране)
      first_draw_num=3 (первый элемент меню на экране)
      select_draw_num=2 (четвертый пункт при показе пунктов с 3 по 5 на экране будет находится на второй строке)
      Тогда при нажатии кнопки вверх например нам надо проверить что мы не уперлись в экран, если мы не уперлись в экран то просто уменьшаем позицию на экране на единицу:
      if (select_draw_num>1) {select_draw_num=select_draw_num-1);
      Если всеже мы уперлись в экран то надо проверить какие элементы меню отображаются на экране, можем ли мы промотать меню ближе к началу:
      if (first_draw_num>1) {first_draw_num=first_draw_num-1);
      Аналогично делается и при нажатии кнопки вниз, только меняются знаки и сравнение делается не с 1, а с переменной хранящей в себе максимальное количество отображаемых на экране пунктов, соответственно если мы не уперлись в нижний край экрана:
      if (select_draw_num<max_draw_num) {select_draw_num=select_draw_num+1);
      Если мы не уперлись в конец меню:
      if (first_draw_num+max_draw_num<max_menu_count) {first_draw_num=first_draw_num+1);
      переменная max_menu_count — количество пунктов в текущем меню.

      После останется только вывести на экран пункты меню с first_draw_num по first_draw_num+max_draw_num-1 и отобразить выделение вокруг пункта меню select_draw_num

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


      1. FisHlaBsoMAN
        11.05.2015 10:58

        Годно. Надо будет попробовать. Спасибо за алгоритм.


  1. abrakada
    10.05.2015 11:41

    Любопытно, как раз собирался искать меню для ардуины и GLCD. Единственное как-то неуверен насчет подтяжки кнопок к питанию. А если МК запустится раньше, чем питание подтяжки преодолеет барьер логической единицы — тогда будет ложное срабатывание?


    1. kumbr_87
      10.05.2015 12:28

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


    1. radiolok Автор
      10.05.2015 12:44

      Настройка портов ввода-вывода для кнопок происходит раньше, чем меню пропишет себя в обработчике кнопок.


  1. Int_13h
    10.05.2015 16:28

    А я вот сижу сочиняю многоуровневое меню на одном светодиоде по мотивам менюх автосигнализаций :) Не везде можно дисплейчик вставить.


  1. spot62
    10.05.2015 16:55

    проще использовать связанный список:

    struct menu_item {
      struct menu_item* parent; // указатель на родителя
      struct menu_item* child;    // указатель на потомка
      struct menu_item* prev;    // предыдущий в списке
      struct menu_item* next;    //  следующий в списке
      const char* name;            //  имя 
      void* data;                        // данные пункта
      int (*do_left)(void*);          //  действие для кнопок
      int (*do_right)(void*);
      int (*do_up)(void*);
      int (*do_down)(void*);
    }
    

    в принципе, чтобы разрядить меню можно вынести data и do_xxxx() в отдельную структуру, поскольку не все пункты будут иметь какую-то настройку (будут директорией)


    1. radiolok Автор
      11.05.2015 10:43
      +1

      Нечто похожее реализовано вот в Этом проекте.


      1. spot62
        11.05.2015 11:44

        да, наверное, но все равно как-то недодумано.
        Кстати, parent тоже можно вынести отдельно для экономии памяти. Например, в структуру меню


  1. maverickcy
    11.05.2015 00:07

    А кто бы мне помог присосаться к жк драйверу msm5265?


    1. radiolok Автор
      11.05.2015 10:53

      Судя по датащиту, подключение возможно организовать по SPI, загружая 160-бит данных в сдвиговый регистр на один контроллер (каждый дает 80х2 точек, контроллеры стекируются).
      Единственное что нужно знать для конкретного дисплея — сколько контроллеров на нем и в какой последовательности пронумерованы пикселы.
      Изображение будет удобно хранить в формате XBM — оно будет сразу готово к загрузке.


      1. maverickcy
        11.05.2015 17:39

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


        1. radiolok Автор
          11.05.2015 21:27

          В отличие от ks010, nt7534 и многих других, вашим драйвером очень просто управлять. Не надо реализовывать никакие команды настройки, достаточно просто загружать в сдвиговый регистр данные. В этом, правда, кроется его недостаток — драйвер тупой. Все нужно делать за него.


          1. maverickcy
            11.05.2015 22:24

            Для текста ведь это не так критично?
            Мне пока не ясно даже, как его припиновать к ардуине.