В этот раз я задумался о том, чтобы спрятать в велосипед 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


К модулю прилагается 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


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


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


micropython


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
<type '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()

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