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

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()Проект приветствует любую посильную помощь. Если вам понравился проект и/или эта статья — не забудьте оставить лайк на гитхабе.
