Micropython на GSM+GPS модуле A9G

В этот раз я задумался о том, чтобы спрятать в велосипед GPS-трэкер в качестве меры предосторожности. На рынке есть масса автономных устройств для слежения за автомобилями, грузом, велосипедами, багажом, детьми и животными. Подавляющее большинство из них взаимодействуют с пользователем с помощью СМС. Более дорогие варианты предоставляют функциональность Find my phone, но привязаны к конкретному онлайн-сервису.
В идеале хотелось бы иметь полный контроль над трекером: использовать его в удобном режиме без СМС и регистрации. Поверхностное гугление вывело меня на пару модулей из поднебесной, один из которых, A9G pudding board, я и заказал (~15$).

Модуль

Эта статья о том, как я заставил работать python на этом модуле.

Если A9G — аналог ESP (производитель, кстати, один и тот же), то сам pudding board является аналогом платы NodeMCU за исключением того, что на pudding board нет встроенного конвертера USB-UART. Зато есть много другого интересного. Спецификации от производителя:


  • ядро 32 bit (RISC), до 312MHz
  • 29x GPIO (все распаяны, в это число включены все интерфейсы)
  • часы и watchdog
  • 1x интерфейс USB 1.1 (я его там не нашел, но копирую с офсайта) и microUSB для питания
  • 2x UART (+1 сервисный)
  • 2x SPI (не пробовал)
  • 3x I2C (не пробовал)
  • 1x SDMMC (с физическим слотом)
  • 2x аналоговых входа (10 бит, возможно, один из них используется контроллеров литиевых аккумуляторов)
  • 4Mb flash
  • 4Mb PSRAM
  • ADC (микрофон, физически существует на плате) и DAC (динамик, отсутствует)
  • контроллер заряда аккумулятора (самого аккумулятора нет)
  • собственно, GSM (800, 900, 1800, 1900 MHz) с SMS, голосом и GPRS
  • GPS, подключенный через UART2 (есть модуль «A9» без него)
  • слот для SIM (nanoSIM)
  • две кнопки (одна reset, другая — включение и программируемая функция)
  • два светодиода

Рабочее напряжение 3.3В, входное напряжение — 5–3.8В (в зависимости от подключения). Вообще, модуль имеет всё необходимое железо для того, чтобы собрать из него простенький кнопочный мобильный аппарат. Но из примеров создаётся впечатление, что китайцы его покупают для продажи из автоматов или автоматов с азартными играми или что-то вроде этого. Альтернативами модулю являются довольно популярные модули SIM800, у которых, к сожалению, нет SDK в свободном доступе (т.е. модули продаются как AT модемы).

К модулю прилагается SDK на удовлетворительном английском. Устанавливается под Ubuntu, но предпочтительными являются Windows и контейнеры. Всё работает через тыкание в GUI: ESPtool для этого модуля только предстоит зареверсить. Сама прошивка собирается Makefile-ом. Дебаггер наличествует: прежде чем зависнуть, модуль вываливает stack trace в сервисный порт. Но лично я так и не смог перевести адреса в строчки кода (gdb сообщает, что адреса ничему не соответствуют). Вполне возможно, что это связано с плохой поддержкой Linux как такового. Соответственно, если хотите повозиться с модулем — попробуйте это сделать под Windows (и отписаться на github). В противном случае вот инструкция для Linux. После установки нужно проверить правильность путей в .bashrc и удалить (переименовать) все файлы CSDTK/lib/libQt*: иначе, прошивальщик (он же дебаггер) просто не запустится из-за конфликта с, вероятно, установленным libQt.

Прошивальщик

К прошивальщику идёт инструкция.

Тут всё сложнее, чем, на NodeMCU. Модули выглядят похоже, но на pudding board нет USB-TTY чипа и microUSB используется только для питания. Соответственно, вам понадобится USB-TTY на 3.3V. А лучше — два: один для дебаг порта и ещё один для UART1: первый используется для заливки прошивки, а второй вы сможете использовать как обычный терминал. Чтобы не тащить все эти сопли к компьютеру я дополнительно приобрел USB разветвитель на 4 порта с двухметровым проводом и внешним блоком питания (обязателен). Суммарная стоимость этого набора с самим модулем составит 25–30$ (без блока питания: используйте от телефона).

Модуль приходит с AT прошивкой: можно подключить к 3.3В ардуине и использовать в качестве модема через UART1. Свои прошивки пишутся на C. make создает два файла прошивки: один шьётся около минуты, другой — достаточно быстро. Шить можно только один из этих файлов: первый раз — большой, последующие разы — маленький. Суммарно, у меня в процессе разработки на рабочем столе открыта китайская SDK (coolwatcher) для управления модулем, miniterm в качестве stdio и редактор кода.

Содержание API отражает список наверху и напоминает ESP8266 в свои ранние дни: у меня ушло часа 3 на то, чтобы запустить HelloWorld. К сожалению, набор функций, доступных пользователю, весьма ограничен: к примеру, нет доступа к телефонной книге на SIM-карте, низкоуровневой информации о подключении к сотовой сети и тому прочее. Документация по API ещё менее полная, поэтому опираться приходится на примеры (которых два десятка) и include-файлы. Тем не менее, модуль может очень многое вплоть до SSL-подключений: очевидно, производитель сфокусировался на наиболее приоритетных функциях.

Впрочем, программирование китайских микроконтроллеров посредством китайского API надо любить. Для всех остальных производитель начал портировать micropython на этот модуль. Я решил попробовать себя в open-source проекте и продолжить это доброе дело (ссылка в конце статьи).

logo

Micropython — это open-source проект портирующий cPython на микроконтроллеры. Разработка ведётся в двух направлениях. Первое — это поддержка и развитие общих для всех микроконтроллеров core-библиотек, описывающих работу с основными типами данных в python: объекты, функции, классы, строки, атомарные типы и тому прочее. Второе — это, собственно, порты: для каждого микроконтроллера необходимо «научить» библиотеку работать с UART для ввода-вывода, выделить стэк под виртуальную машину, указать набор оптимизаций. Опционально, описывается работа с железом: GPIO, питание, беспроводная связь, файловая система.
Всё это пишется на чистых С с макросами: у micropython есть набор рекомендованных рецептов начиная с объявления строк в ROM до написания модулей. В дополнение к этому, полностью поддерживаются самописные модули на питоне (главное — не забывать об объёме памяти). Кураторы проекта ставят целью возможность запустить джангу (картинка с буханкой хлеба). В качестве рекламы: проект продаёт собственную плату для студентов pyboard, но также популярны порты для модулей ESP8266 и ESP32.

Когда прошивка готова и залита — вы просто подключаетесь к микроконтроллеру через UART и попадаете в питонский REPL.

$ miniterm.py /dev/ttyUSB1 115200 --raw
MicroPython cd2f742 on 2017-11-29; unicorn with Cortex-M3
Type "help()" for more information.
>>> print("hello")
hello

После этого можно начинать писать на почти обычном python3, не забывая об ограничениях памяти.

Модуль A9G не поддерживается официально (список официально поддерживаемых модулей доступен в micropython/ports, их около десятка). Тем не менее, производитель железа форкнул micropython и создал окружение для порта A9G: micropython/ports/gprs_a9, за что ему большое спасибо. На момент, когда я заинтересовался этим вопросом, порт успешно компилировался и микроконтроллер приветствовал меня REPL. Но, к сожалению, из сторонних модулей присутствовала только работа с файловой системой и GPIO: ничего, связанного с беспроводной сетью и GPS доступно не было. Я решил исправить эту недоработку и поставил себе цель портировать все функции, необходимые для GPS-трекера. Официальная документация на этот случай излишне лаконична: поэтому, пришлось ковыряться в коде.


С чего начать

Первым делом идём в micropython/ports и копируем micropython/ports/minimal в новую папку, в которой будет находится порт. Затем, редактируем main.c под вашу платформу. Имейте ввиду, что вся вкуснятина находится в функции main, где нужно вызвать инициализатор mp_init(), предварительно подготовив для него настройки микроконтроллера и стэк. Потом, для event-driven API, необходимо вызвать pyexec_event_repl_init() и скармливать вводимые через UART символы в функцию pyexec_event_repl_process_char(char). Это и обеспечит взаимодействие через REPL. Второй файл — micropython/ports/minimal/uart_core.c описывает блокирующий ввод и вывод в UART. Привожу оригинальный код для STM32 для тех, кому лень искать.

main.c

int main(int argc, char **argv) {
    int stack_dummy;
    stack_top = (char*)&stack_dummy;

    #if MICROPY_ENABLE_GC
    gc_init(heap, heap + sizeof(heap));
    #endif
    mp_init();
    #if MICROPY_ENABLE_COMPILER
    #if MICROPY_REPL_EVENT_DRIVEN
    pyexec_event_repl_init();
    for (;;) {
        int c = mp_hal_stdin_rx_chr();
        if (pyexec_event_repl_process_char(c)) {
            break;
        }
    }
    #else
    pyexec_friendly_repl();
    #endif
    //do_str("print('hello world!', list(x+1 for x in range(10)), end='eol\\n')", MP_PARSE_SINGLE_INPUT);
    //do_str("for i in range(10):\r\n  print(i)", MP_PARSE_FILE_INPUT);
    #else
    pyexec_frozen_module("frozentest.py");
    #endif
    mp_deinit();
    return 0;
}

uart_core.c

// Receive single character
int mp_hal_stdin_rx_chr(void) {
    unsigned char c = 0;
#if MICROPY_MIN_USE_STDOUT
    int r = read(0, &c, 1);
    (void)r;
#elif MICROPY_MIN_USE_STM32_MCU
    // wait for RXNE
    while ((USART1->SR & (1 << 5)) == 0) {
    }
    c = USART1->DR;
#endif
    return c;
}

// Send string of given length
void mp_hal_stdout_tx_strn(const char *str, mp_uint_t len) {
#if MICROPY_MIN_USE_STDOUT
    int r = write(1, str, len);
    (void)r;
#elif MICROPY_MIN_USE_STM32_MCU
    while (len--) {
        // wait for TXE
        while ((USART1->SR & (1 << 7)) == 0) {
        }
        USART1->DR = *str++;
    }
#endif
}

После этого нужно переписать Makefile используя рекомендации / компилятор от производителя: тут всё индивидуально. Всё, этого в идеале должно хватить: собираем, заливаем прошивку и видим REPL в UART.
После оживления micropython необходимо позаботиться о его хорошем самочувствии: настроить сборщик мусора, правильную реакцию на Ctrl-D (soft reset) и некоторые другие вещи, на которых я не буду останавливаться: см. файл mpconfigport.h.


Создаём модуль

Самое интересное — написание собственных модулей. Итак, модуль (не обязательно, но желательно) начинается с собственного файла mod[имя].c, который добавляется Makefile (переменная SRC_C если следовать конвенции). Пустой модуль выглядит следующим образом:

// nlr - non-local return: в C исключений нет, и чтобы их имитировать используется goto-магия и ассемблер.
// Функция nlr_raise прерывает исполнение кода в точке вызова и вызывает ближайший по стэку обработчик ошибок.
#include "py/nlr.h"
// Основные питонские типы. К примеру, структура mp_map_elem_t, статичный словарь, объявлен именно там.
#include "py/obj.h"
// Высокоуровневое управление рантаймом. mp_raise_ValueError(char* msg) и mp_raise_OSError(int errorcode) находятся именно здесь.
// В дополнение, набор функций mp_call_function_* используется для вызова питонских Callable (полезно для callback-логики).
#include "py/runtime.h"
#include "py/binary.h"
// Общий header для всех модулей: тут как хотите так и организовывайте
#include "portmodules.h"

// Словарь со списком всех-всех-всех атрибутов модуля. Имена задаются через макрос MP_QSTR_[имя атрибута]. MP_OBJ_NEW_QSTR делает питонскую обертку.
// В этих двух макросах используются всевозможные оптимизации чтобы не хранить строку в RAM.
// Единственная запись на текущий момент - имя модуля в магическом поле __name__
STATIC const mp_map_elem_t mymodule_globals_table[] = {
    { MP_OBJ_NEW_QSTR(MP_QSTR___name__), MP_OBJ_NEW_QSTR(MP_QSTR_mymodule) },
};

// Питонская обёртка вокруг словаря сверху
STATIC MP_DEFINE_CONST_DICT (mp_module_mymodule_globals, mymodule_globals_table);

// Объявление самого модуля: объект нашего модуля наследует объект базового модуля и содержит список атрибутов сверху
const mp_obj_module_t mp_module_mymodule = {
    .base = { &mp_type_module },
    .globals = (mp_obj_dict_t*)&mp_module_mymodule_globals,
};

Конечно, порт сам по себе не узнает о константе mp_module_mymodule: её необходимо добавить в переменную MICROPY_PORT_BUILTIN_MODULES в настройках порта mpconfigport.h. Кстати, нескучные обои имя чипа и название порта меняются тоже там. После всех этих изменений можно попытаться скомпилировать модуль и импортировать его из REPL. У модуля будет доступен только один атрибут __name__ с именем модуля (отличный случай для проверки автодополнения в REPL через Tab).

>>> import mymodule
>>> mymodule.__name__
'mymodule'


Константы

Следующий этап по сложности — добавление констант. Константы часто необходимы для настроек (INPUT, OUTPUT, HIGH, LOW и т.п.) Тут всё достаточно просто. Вот, к примеру, константа magic_number = 10:

STATIC const mp_map_elem_t mymodule_globals_table[] = {
    { MP_OBJ_NEW_QSTR(MP_QSTR___name__), MP_OBJ_NEW_QSTR(MP_QSTR_mymodule) },
    { MP_OBJ_NEW_QSTR(MP_QSTR_magic_number), MP_OBJ_NEW_SMALL_INT(10) },
};

Тестируем:

>>> import mymodule
>>> mymodule.magic_number
10


Функции

Добавление функции в модуль следует общему принципу: объявить, обернуть, добавить (привожу чуть более сложный пример, чем в документации).

// Объявляем
STATIC mp_obj_t conditional_add_one(mp_obj_t value) {
    // Получаем целое int. Если передали строку или любой другой несовместимый объект - нет проблем: исключение вывалится автоматически.
    int value_int = mp_obj_get_int(value);
    value_int ++;
    if (value_int == 10) {
        // Возврат None
        return mp_const_none;
    }
    // Возврат питонского int
    return mp_obj_new_int(value);
}

// Оборачиваем функцию одного аргумента. Для заинтересованных предлагаю посмотреть
// runtime.h относительно других вариантов.
STATIC MP_DEFINE_CONST_FUN_OBJ_1(conditional_add_one_obj, conditional_add_one);

// Добавляем
STATIC const mp_map_elem_t mymodule_globals_table[] = {
    { MP_OBJ_NEW_QSTR(MP_QSTR___name__), MP_OBJ_NEW_QSTR(MP_QSTR_mymodule) },
    { MP_OBJ_NEW_QSTR(MP_QSTR_magic_number), MP_OBJ_NEW_SMALL_INT(10) },
    { MP_OBJ_NEW_QSTR(MP_QSTR_conditional_add_one), (mp_obj_t)&conditional_add_one_obj },
};

Тестим:

>>> import mymodule
>>> mymodule.conditional_add_one(3)
4
>>> mymodule.conditional_add_one(9)
>>> 


Классы (типы)

С классами (типами) всё тоже относительно просто. Вот пример из документации (ну почти):

// Пустая таблица атрибутов класса
STATIC const mp_map_elem_t mymodule_hello_locals_dict_table[] = {};

// Словарная обёртка
STATIC MP_DEFINE_CONST_DICT(mymodule_hello_locals_dict, mymodule_hello_locals_dict_table);

// Структура, определяющая объект, являющийся типом
const mp_obj_type_t mymodule_helloObj_type = {
    // Наследуем базовый тип
    { &mp_type_type },
    // Имя: helloObj
    .name = MP_QSTR_helloObj,
    // Атрибуты
    .locals_dict = (mp_obj_dict_t*)&mymodule_hello_locals_dict,
};

// Добавляем в модуль
STATIC const mp_map_elem_t mymodule_globals_table[] = {
    { MP_OBJ_NEW_QSTR(MP_QSTR___name__), MP_OBJ_NEW_QSTR(MP_QSTR_mymodule) },
    { MP_OBJ_NEW_QSTR(MP_QSTR_magic_number), MP_OBJ_NEW_SMALL_INT(10) },
    { MP_OBJ_NEW_QSTR(MP_QSTR_conditional_add_one), (mp_obj_t)&conditional_add_one_obj },
    { MP_OBJ_NEW_QSTR(MP_QSTR_conditional_add_one), (mp_obj_t)&mymodule_helloObj_type },
};

Тестим:

>>> mymodule.helloObj

Получившийся тип можно наследовать, сравнивать, но у него нет ни конструктора ни каких-либо ассоциированных данных. Данные добавляются «рядом» с конструктором: предлагается создать отдельную структуру, в которой будет хранится отдельно питонский тип и отдельно — произвольный набор данных.

// Произвольная над-структура. Да, с именами путаница
typedef struct _mymodule_hello_obj_t {
    // Питонский тип
    mp_obj_base_t base;
    // Какие-то данные
    uint8_t hello_number;
} mymodule_hello_obj_t;

Как взаимодействовать с этими данными? Один из самых сложных способов — через конструктор.

// Функция-конструктор, принимающая тип (который, вполне возможно, отличается от mymodule_helloObj_type
// по той причине, что тип был наследован чем-то другим), количество аргументов (args и kwargs) и
// указатель на сами аргументы в том же порядке: args, kwargs
STATIC mp_obj_t mymodule_hello_make_new( const mp_obj_type_t *type, size_t n_args, size_t n_kw, const mp_obj_t *args ) {
    // Проверить количество аргументов
    mp_arg_check_num(n_args, n_kw, 1, 1, true);
    // Создать экземпляр
    mymodule_hello_obj_t *self = m_new_obj(mymodule_hello_obj_t);
    // Положить тип куда надо
    self->base.type = &mymodule_hello_type;
    // Присвоить данные
    self->hello_number = mp_obj_get_int(args[0])
    // Вернуть экземпляр
    return MP_OBJ_FROM_PTR(self);
    // Второй аргумент в __init__, видимо, проигнорировали
}

// Конструктор должен сидеть в поле make_new
const mp_obj_type_t mymodule_helloObj_type = {
    { &mp_type_type },
    .name = MP_QSTR_helloObj,
    .locals_dict = (mp_obj_dict_t*)&mymodule_hello_locals_dict,
    // Конструктор
    .make_new = mymodule_hello_make_new,
};

Из других полей есть ещё .print, и, полагаю, вся остальная магия Python3.

Но make_new вовсе не обязателен для получения экземпляра объекта: инициализацию можно производить в произвольной функции. Вот неплохой пример из micropython/ports/esp32/modsocket.c:

// Другая сигнатура функции: количество аргументов и указатель на аргументы
STATIC mp_obj_t get_socket(size_t n_args, const mp_obj_t *args) {
    socket_obj_t *sock = m_new_obj_with_finaliser(socket_obj_t);
    sock->base.type = &socket_type;
    sock->domain = AF_INET;
    sock->type = SOCK_STREAM;
    sock->proto = 0;
    sock->peer_closed = false;
    if (n_args > 0) {
        sock->domain = mp_obj_get_int(args[0]);
        if (n_args > 1) {
            sock->type = mp_obj_get_int(args[1]);
            if (n_args > 2) {
                sock->proto = mp_obj_get_int(args[2]);
            }
        }
    }

    sock->fd = lwip_socket(sock->domain, sock->type, sock->proto);
    if (sock->fd < 0) {
        exception_from_errno(errno);
    }
    _socket_settimeout(sock, UINT64_MAX);

    return MP_OBJ_FROM_PTR(sock);
}

// Обёртка для функции с 0-3 аргументами
STATIC MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(get_socket_obj, 0, 3, get_socket);


Привязанные методы (bound methods)

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

// Ещё один пример сигнатуры: количество аргументов строго равно 1 (self)
STATIC mp_obj_t mymodule_hello_increment(mp_obj_t self_in) {
    mymodule_hello_obj_t *self = MP_OBJ_TO_PTR(self_in);
    self->hello_number += 1;
    return mp_const_none;
}

// Обёртка функции одной переменной
MP_DEFINE_CONST_FUN_OBJ_1(mymodule_hello_increment_obj, mymodule_hello_increment);

// Добавляем в аттрибуты под именем 'inc'
STATIC const mp_map_elem_t mymodule_hello_locals_dict_table[] = {
    { MP_OBJ_NEW_QSTR(MP_QSTR_inc), (mp_obj_t)&mymodule_hello_increment_obj },
}

Всё!

>>> x = mymodule.helloObj(12)
>>> x.inc()


Все остальные атрибуты: getattr, setattr

Как насчёт добавления не-функций, использования @property и вообще собственного __getattr__? Пожалуйста: это делается вручную в обход mymodule_hello_locals_dict_table.

// Функция со специфической сигнатурой ...
STATIC void mymodule_hello_attr(mp_obj_t self_in, qstr attr, mp_obj_t *dest) {
    mymodule_hello_obj_t *self = MP_OBJ_TO_PTR(self_in);
    if (dest[0] != MP_OBJ_NULL) {
        // __setattr__
        if (attr == MP_QSTR_val) {
            self->val = dest[1];
            dest[0] = MP_OBJ_NULL;
        }
    } else {
        // __getattr__
        if (attr == MP_QSTR_val) {
            dest[0] = self->val;
        }
    }
}

// ... идёт прямиком в магический attr
const mp_obj_type_t mymodule_helloObj_type = {
    { &mp_type_type },
    .name = MP_QSTR_helloObj,
    // Словарь больше не используется
    //.locals_dict = (mp_obj_dict_t*)&mymodule_hello_locals_dict,
    .make_new = mymodule_hello_make_new,
    // Вместо него - attr
    .attr = mymodule_hello_attr,
};

Что-то больно лаконичный attr получился, скажете вы. Где же все эти mp_raise_AttributeError (прим: такая функция не существует)? На самом деле, AttributeError будет вызван автоматически. Секрет в том, что dest — это массив из двух элементов. Первый элемент имеет смысл «вывода», write-only: он принимает значение MP_OBJ_SENTINEL если значение необходимо записать и MP_OBJ_NULL если его нужно прочитать. Соответственно, на выходе из функции ожидается MP_OBJ_NULL в первом случае и что-то mp_obj_t во втором. Второй элемент — «ввод», read-only: принимает значение объекта для записи, если значение необходимо записать и MP_OBJ_NULL, если его необходимо прочитать. Менять его не надо.

Вот и всё, можно проверять:

>>> x = mymodule.helloObj(12)
>>> x.val = 3
>>> x.val
3

Самое интересное — что автодополнение по Таb в REPL по-прежнему работает и предлагает .val! Я, если честно, никак не эксперт в C, поэтому могу только предполагать как это происходит (переопределением оператора '==').


Порт

Возвращаясь к модулю A9G, я описал поддержку всех основных функций, а именно, SMS, GPRS (usockets), GPS, управление питанием. Теперь можно залить что-то вроде этого на модуль и оно будет работать:

import cellular as c
import usocket as sock
import time
import gps
import machine

# Ожидаем сеть
print("Waiting network registration ...")
while not c.is_network_registered():
    time.sleep(1)
time.sleep(2)

# Включаем GPRS
print("Activating ...")
c.gprs_activate("internet", "", "")

print("Local IP:", sock.get_local_ip())

# Включаем GPS
gps.on()

# Отдаём данные на thingspeak
host = "api.thingspeak.com"
api_key = "some-api-key"
fields = ('latitude', 'longitude', 'battery', 'sat_visible', 'sat_tracked')
# Какая прелесть, что эта мешанина работает на микроконтроллере!
fields = dict(zip(fields, map(lambda x: "field{}".format(x+1), range(len(fields))) ))

x, y = gps.get_location()
level = machine.get_input_voltage()[1]
sats_vis, sats_tracked = gps.get_satellites()

s = sock.socket()
print("Connecting ...")
s.connect((host, 80))
print("Sending ...")
# Пока что сокеты мало что поддерживают, поэтому запрос через сырой HTTP. В будущем можно будет использовать библиотеки на чистом питоне для HTTP, SSL и прочего
print("Sent:", s.send("GET /update?api_key={}&{latitude}={:f}&{longitude}={:f}&{battery}={:f}&{sat_visible}={:d}&{sat_tracked}={:d} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n".format(
    api_key,
    x,
    y,
    level,
    sats_vis,
    sats_tracked,
    host,
    **fields
)))
print("Receiving ...")
print("Received:", s.recv(128))
s.close()

Проект приветствует любую посильную помощь. Если вам понравился проект и/или эта статья — не забудьте оставить лайк на гитхабе.

© Habrahabr.ru