Пишем прошивку для Arduino на С++ с REST управлением через последовательный порт и экранчиком

в 19:45, , рубрики: api, arduino, c++, diy или сделай сам, rest, ненормальное программирование, робототехника

image

Это второй пост про Wi-Fi роботанк. В нем будет написано как не надо делать прошивки, если вы суровый программист микроконтроллеров и как можно сделать, если нужна максимальная наглядность и возможность рулить прошивкой почти как веб-приложением прямо с терминала.

То есть, например, отправив в последовательный порт что-то типа

battery?act=status

получим в ответ что-то типа

{ "status": "OK", "minValue": 600, "maxValue": 900, "value":750, "percent": 50 }

Для тех, кому лень читать статью, сразу ссылка на github и Яндекс-диск, у кого гитхаб залочен (спасибо MaximChistov).

Итак, в какой-то момент я понял, что без ардуины мне никак не обойтись, достал из закромов Arduino Nano и купил к нему экранчик с I2C переходником. Как я пытался подключить экран, это отдельная песня с припевом, но в итоге оказалось, что у меня был перебитый земляной провод и I2C адрес экрана не соответствовал ни одному из описаний в инете. После успешного запуска HelloWorld из примеров я начал думать и что же мне со всем этим делать.

Что имелось в начале

На старте имелось следующее:

  1. Arduino Nano
  2. LCD экран 16x2 с переходником на I2C как тут
  3. Библиотека I2C экрана

Список задач:

  1. Управление по типу HTTP REST
  2. Отображение на экране сообщений, уведомлений (больше приоритет) и иконок по типу system tray
  3. Определение уровня заряда батарейки
  4. Управление питанием и выстрелом пушки

Чуть-чуть про пушку и батарейку. Для определения уровня заряда я подключил плюс батарейки через делитель из 2х резисторов по 43КОм на вход A0. Осталось только откалибровать верхнее и нижнее значения. Пушка у меня включена не постоянно. При подаче единицы на цифровую ногу ардуины через полевой транзистор запитывается сервопривод наведения пушки и заодно лазерный прицел. Для выстрела необходимо постепенно с помощью ШИМа (чтоб снизить помехи по питанию) открыть второй транзистор, включающий мотор самой пушки и также постепенно его выключить при замыкании контакта, который сигнализирует о том, что выстрел случился.
Также сразу уточню, что посмотрев на объем RAM у ATMega328, который всего 2 килобайта, я испугался и не стал использовать динамическую память. Только стек, только хардкор.

Базовый скелет для обработки REST запросов

Поняв, что мне надо от ардуины и прогнав тестовые примеры, я радостно открыл Arduino IDE и завис. Мне явно было мало одного файла и хотелось нормального C++ с подсветкой и автокомплитом. Через некоторое время пришло озарение. Исходник оформляется в виде библиотеки, лежащей в sketchbook/libraries, а сам скетч создает единственный объект с методами Setup() и Loop() и соответственно их вызывает.

На всякий случай, уточню, что у Arduino SDK есть две абстракции ввода-вывода. Это Print, на который можно как это ни странно выводить и Stream, который унаследован от Print.

Базовые сущности получились такие:

  1. Команда. Создается из строки вида /commandName?arg1=value1&arg2=value2 и содержит простейший парсер аргументов.
    Интерфейс

    class Command
    {
    public:
        Command( char* str );
    
        inline const char* Name() const { return _name; }
        inline const char* Params() const { return _params; }
    
        const char* GetParam( const char* paramName );
    
        bool GetIntParam(  const char* paramName, int* out );
        bool GetDoubleParam(  const char* paramName, double* out );
        bool GetStringParam(  const char* paramName, const char** out );
    
    private:
        char*  _name;
        char*  _params;
        size_t _paramsLen;
    };
  2. Обработчик команды. Обрабатывает команду и шлет ответ в виде JSON.
    Интерфейс
    class CommandHandler
    {
    public:
        virtual void HandleCommand( Command& cmd, Print &output );
    
        static void SendError( Print &output, const char* err );
        static void SendOk( Print &output );
        static void SendErrorFormat( Print &output, const char* fmt, ... );
    };
    
  3. Обработчик запросов. Создает команду и маршрутизирует ее на соответствующий обработчик, если таковой есть.
    Интерфейс

    struct HandlerInfo
    {
        const char* command;
        CommandHandler* handler;
    
        HandlerInfo(): command(0), handler(0) {}
        HandlerInfo(const char* c, CommandHandler* h): command(c), handler(h) {}
    };
    
    class RequestHandler
    {
    public:
        RequestHandler();
    
        void SetHandlers( HandlerInfo* handlers, size_t count );
        void HandleRequest( char* request, Print& output );
    
    private:
        HandlerInfo* _handlers;
        size_t _handlersCount;
    };

Для обработки запросов с последовательного порта (или еще какого Stream'a) был написан

StreamRequestHandler

class StreamRequestHandler : public RequestHandler
{
public:
    static const size_t BufferSize = 128;

    StreamRequestHandler( Stream& stream );
    void Proceed();

private:
    Stream&     _stream;
    char        _requestBuf[BufferSize];
    size_t      _requestLen;
};

Настало время все это протестировать. Для этого надо создать экземпляр StreamRequestHandler, передав ему в конструктор Serial (который на самом деле синглтон класса HardwareSerial), передать в SetHandlers массив обработчиков команд и дергать метод Proceed где-то внутри loop().
Первым обработчиком стал

PingHandler

class PingHandler : public CommandHandler
{
public:
    virtual void HandleCommand( Command& cmd, Print &output )
    {
        SendOk(output);
    }
};

Работа с экраном

После успешного отклика на пинг захотелось прочитать что-нибудь на экранчике и там же увидеть заряд батарейки. Стандартный экран 16x2 имеет, как ни странно, две строки по 16 символов, а также позволяет переопределить изображения для первых восьми символов. Исходя из этого я решил первые 8 знакомест верхней строки отвести под system tray, вторые 8 пока не трогать, а все сообщения выводить в нижнюю строку.
Обычные сообщения будут выводиться бегущей строкой, а приоритетные будут не длиннее 16 символов и выводиться по центру, прерывая на время отображения, если надо, текущую бегущую строку. Чтобы не городить связные списки на указателях, максимальное количество сообщений в очереди было ограничено до 8 обычных и 4 приоритетных, что позволило использовать для их хранения обычный кольцевой буфер.
Иконками в system tray стали обычные символы с кодами от 0 до 7. Для показа иконки надо сначала ее зарезервировать, получив iconID (который просто код символа), после чего для iconID можно задать само изображение. Анимированную иконку можно получить постоянно меняя картинку. Экран такое издевательство выдерживает без проблем.

Что в итоге получилось

class DisplayController
{
public:
    static const uint8_t        MaxIcons = 8;
    static const uint8_t        Rows = 2;
    static const uint8_t        Cols = 16;
    
    static const uint8_t        IconsRow = 0;
    static const uint8_t        IconsCol = 0;
    
    static const int            MaxMessages = 8;
    static const unsigned       MessageInterval = 150;
    static const uint8_t        MessageRow = 1;
    static const uint8_t        MessageCol = 0;
    static const uint8_t        MessageLen = 16;

    static const int            MaxAlerts = 4;
    static const unsigned       AlertInterval = 1000;
    static const uint8_t        AlertRow = 1;
    static const uint8_t        AlertCol = 0;
    static const uint8_t        AlertLen = 16;
    
    
private:
    struct Message
    {
        const char*     text;
        int             length;
        int             position;
        
        inline Message() 
        {
            Clear();
        }
        
        inline void Clear()
        {
            text = 0;
            length = 0;
            position = 0;
        }
    };

    struct Alert
    {
        const char*     text;
        int             length;
        bool            visible;
        
        inline Alert() 
        {
            Clear();
        }
        
        inline void Clear()
        {
            text = 0;
            length = 0;
            visible = false;
        }
    };
    
public:
    DisplayController( uint8_t displayAddr );

    void Init();
    
    int8_t AllocateIcon();
    void ReleaseIcon(int8_t iconId);

    void ChangeIcon(int8_t iconId, uint8_t iconData[]);
    
    void UpdateIcons();
    void Proceed();
    
    bool PutMessage( const char* text );
    bool PutAlert( const char* text );
    
    inline bool HasMessages() { return _messages[_messageHead].text != 0; }
    inline bool HasAlerts() { return _alerts[_alertHead].text != 0; }
    
    void UpdateMessage();
    void UpdateAlert();
    
private:    
    LiquidCrystal_I2C   _lcd;
    
    int                 _iconBusy;

    unsigned long       _messageTick;

    Message             _messages[MaxMessages];
    int                 _messageHead;
    int                 _messageTail;

    unsigned long       _alertTick;
    Alert               _alerts[MaxAlerts];
    int                 _alertHead;
    int                 _alertTail;
};

А дальше все банально

Осталось только дописать логику обработчиков команд и зарегистрировать их в маршрутизаторе. Обработчиков, кроме PingHandler получилось целых три: батарейка, пушка и общий статус всего и сразу. Особого интереса они не представляют, поэтому, кому интересно, просто гляньте исходник.

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

Собственно, все, спасибо за внимание. Надеюсь, что кому-нибудь мой опыт пригодится. Весь код, кроме скетча лежит на github'е или на Яндекс-диске, а сам скетч выглядит так:

Скетч

#include <Wire.h>
#include <LiquidCrystal_I2C.h>
#include <Tank.h>

Tank tank(19200, 0x3F, A0, 4, 5, 6);

void setup()
{
        tank.Setup();
}

void loop()
{
        tank.Loop();
}

Автор: zabbius

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js