Максим Данилин, 25.01.2023

Как создать приложение для Flipper Zero

Привет!

В этой статье мы разберёмся, как написать собственную программу-приложение для Flipper Zero!

Сердцем гаджета Flipper Zero является 32-битный микроконтроллер STM32. Программирование Дельфина сильно отличается от программирования привычных нам Arduino. Помимо самого микроконтроллера во Флиппере есть радиомодуль, NFC-модуль, кардридер, модуль Bluetooth, дисплей, микросхема управления подсветкой и так далее. Эффективно управлять всеми этими устройствами в одном цикле loop, как это обычно выглядит в среде разработки Arduino, уже нельзя.

На помощь приходит операционная система реального времени или RTOS (real-time operating system). RTOS разграничивает логические части всей программы в разные потоки и сама осуществляет переключение между ними, а также выделяет необходимые для работы потоков ресурсы.

Так как Флиппер построен на чипе STM32, в нём используется, наверное, самая популярная операционка для этого типа микроконтроллеров — FreeRTOS.

Мы не будем подробно разбирать, как реализована операционная система на Flipper Zero, а лучше сосредоточимся на написании приложений.

Это важно! Проект Flipper Zero активно развивается, и на момент выхода данной статьи официальной документации по созданию собственных приложений нет, а сам API полностью не описан. Однако принцип построения приложений вполне понятен в процессе изучения исходного кода встроенных в Флиппер приложений. Вполне возможно, что со временем API изменится, и код из этой статьи станет неактуальным. Разработчики Flipper Zero обещают, что документация появится, когда стабилизируется API. Статья актуальна для прошивки Флиппера 0.75.0.

Прошивку можно собрать на следующих платформах:

  • Windows 10+ с PowerShell и Git (архитектура x86_64).
  • macOS 12+ с Command Line tools (архитектура x86_64 и arm64).
  • Ubuntu 20.04+ с build-essential и Git (архитектура x86_64).

У пользователей macOS не должно быть проблем со сборкой прошивки благодаря Homebrew. Ну а если вы собираете прошивку на Windows и столкнулись с трудностями, попробуйте воспользоваться WSL.

Писать код для собственных приложений мы будем на языке программирования C на настольном компьютере под управлением Linux, а именно Ubuntu 22.04.1 LTS (Jammy Jellyfish).

Установка необходимого софта

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

Сперва скачаем и установим официальную программу qFlipper для работы с Флиппером через графический интерфейс. Мы будем использовать qFlipper для удобной загрузки наших готовых приложений на SD-карту во Флиппере.

Скачиваем версию программы для OS Linux куда-нибудь, например в домашнюю директорию. На момент выхода статьи программа qFlipper имеет версию 1.2.2, а сам файл называется qFlipper-x86_64-1.2.2.AppImage. Также установите необходимую для работы программы библиотеку libfuse2, если её нет в вашей системе.

wget https://update.flipperzero.one/builds/qFlipper/1.2.2/qFlipper-x86_64-1.2.2.AppImage
sudo apt install libfuse2

Установите разрешение на запуск qFlipper и добавьте в систему udev правила доступа к USB Serial-порту для обычного пользователя. Иначе понадобится вести разработку от лица суперпользователя, что небезопасно для всей системы в случае неосторожности.

sudo chmod +x qFlipper-x86_64-1.2.2.AppImage
./qFlipper-x86_64-1.2.2.AppImage rules install

Запустите qFlipper и подключите ваш Flipper Zero к компьютеру по USB.

./qFlipper-x86_64-1.2.2.AppImage

Убедитесь, что ваш Флиппер появился в программе qFlipper, всё работает как положено и установлена свежая прошивка.

qflipper_1

Для быстрой и удобной установки необходимого софта понадобится диспетчер пакетов Homebrew.

Установите Homebrew:

sudo apt install curl
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

После установки добавьте переменные окружения PATH, MANPATH для Homebrew. Это можно сделать, добавив следующую строку в .profile-файл для вашего юзера:

echo 'eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"' >> /home/тут_ваш_username/.profile

Прошивка для Flipper Zero хранится в репозитории flipperzero-firmware на GitHub.

Склонируйте к себе репозиторий прошивки Flipper Zero со всеми модулями. Репозиторий займет чуть больше 2 ГБ пространства.

git clone --recursive https://github.com/flipperdevices/flipperzero-firmware.git

Установите перечисленный в описании репозитория софт:

sudo apt update
sudo apt install openocd clang-format-13 dfu-util protobuf-compiler

Перейдите в директорию репозитория и установите все необходимые пакеты с помощью Homebrew:

cd flipperzero-firmware
brew bundle --verbose

Готово! Прошивка для Флиппера, как и пользовательские приложения, собираются с помощью утилиты fbt (Flipper Build Tool). Для создания собственных приложений нет необходимости каждый раз собирать всю прошивку целиком, однако при первом запуске утилиты fbt будут скачаны необходимые gcc-arm тулчейны.

Соберите прошивку:

./fbt

Все результаты сборки и бинарные файлы будут помещены в директорию /dist.

Пример 1. Простейшее приложение

Создадим простейшее компилируемое приложение.

Мы будем создавать приложение типа FAP (Flipper Application Package). Готовое приложение этого типа представляет собой файл формата .fap. По сути, .fap-приложение — это исполняемый файл .elf с дополнительными встроенными данными.

Кстати! Весь исходный код приложений из этой стати мы выложили на GitHub в репозитории flipperzero-examples.

При создании пользовательские приложения помещаются в отдельные папки в специально организованную директорию applications_user:

ws_code_1

Придумайте имя для приложения. Мы назвали наше первое приложение example_1, этим же именем назвали и папку. В неё помещаются все файлы, которые относятся к вашему приложению: исходный код, изображения и прочее.

В папке example_1 создадим файл исходного кода на языке С — example_1_app.c. Вот как выглядит код простейшего приложения.

#include <furi.h>

int32_t example_1_app(void* p) {
    UNUSED(p);
    return 0;
}

Конвенционально в приложении точкой входа является функция, которая имеет имя приложения и суффикс app. Точка входа в наше приложение — функция example_1_app. Главная функция традиционно возвращает код ошибки числом типа int32_t. Возвращаемый ноль сообщает об отсутствии ошибок.

При компиляции кода для Флиппера любой warning воспринимается как ошибка. Да, ваш код должен быть чистеньким. Неиспользованные в функции аргументы вызывают warning, поэтому для обозначения неиспользуемого указателя p мы используем макрос UNUSED. Реализация данного макроса описана в заголовке furi.h. FURI расшифровывается как «Flipper Universal Registry Implementation». Этим заголовочным файлом мы, по сути, подключаем вce core API Флиппера.

Нашему приложению понадобится иконка. Для пользовательских приложений, которые находятся на самом Флиппере в разделе Applications, в качестве иконок используются изображения PNG с глубиной цвета 1 бит (чёрно-белые) и размером 10×10 пикселей.

Раздел Apllications с пользовательскими приложениями:

flipper_1

Можно использовать одно из уже имеющихся изображений, но всегда лучше добавить что-то своё. В GIMP мы нарисовали вот такой смайл в чёрных очках:

emoji_smile_icon_10x10px

Нарисовать свою иконку можно и в Paint. Файл изображения помещаем в папку нашего приложения example_1.

Помимо исходного кода и иконки нам нужен файл манифеста приложения — application.fam. Этот файл является обязательным. Наш манифест приложения имеет следующий вид:

App(
    appid="example_1",
    name="Example 1 application",
    apptype=FlipperAppType.EXTERNAL,
    entry_point="example_1_app",
    cdefines=["APP_EXAMPLE_1"],
    stack_size=1 * 1024,
    order=90,
    fap_icon="emoji_smile_icon_10x10px.png",
    fap_category="Misc",
)

Разберёмся, за что отвечают данные параметры. Параметры для всех видов приложений:

  • appid — строка, которая используется как ID приложения при конфигурации сборки fbt, а также для разрешения зависимостей и конфликтов. Есть смысл использовать здесь непосредственно имя вашего приложения.
  • name — читабельное имя приложения, которое будет отображаться в меню приложений на Флиппере.
  • apptype — тип приложения. Существуют разные типы для тестовых, системных, сервисных, архивных приложений и для приложений, которые должны быть в главном меню Флиппера. В конце сборки наше приложение будет типа FAP. Для приложений подобного рода используется тип EXTERNAL (FlipperAppType.EXTERNAL).
  • entry_point — точка входа приложения. Имя главной функции, с выполнения которой начнётся работа вашего приложения. Если в качестве точки входа вы хотите использовать функцию C++, то она должна быть обёрнута в extern "C".
  • cdefines — препроцессорное глобальное объявление для других приложений, когда текущее приложение включено в активную конфигурацию сборки.
  • stack_size — размер стека в байтах, выделяемый для приложения при его запуске. Обратите внимание, что выделение слишком маленького стека приведёт к сбою системы из-за переполнения стека, а выделение слишком большого уменьшит полезный объём heap-памяти для обработки данных приложениями.
  • order — порядок приложения внутри своей группы при сортировке записей. Чем ниже значение, тем выше по списку окажется ваше приложение.

Параметры для внешних приложений типа EXTERNAL:

  • fap_icon — путь и имя PNG-изображения размером 10×10 пикселей, которое используется как иконка. Здесь пишем путь и имя нашей PNG-иконки.
  • fap_category — подкатегория приложения. Определяет путь .fap-файла в папке приложений в файловой системе. Может быть пустым. Мы поместили наше приложение в категорию Misc на Флиппере.

Если все файлы на месте, мы можем начинать сборку приложения.

ws_code_2

В терминале переходим в корневую директорию прошивки flipperzero-firmware. Сборка осуществляется командой ./fbt fap_{APPID}, где {APPID} — это ID, указанный в .fam-файле манифеста приложения.

./fbt fap_example_1

Сбилдить все имеющиеся в прошивке приложения FAP можно командой ./fbt faps.

Готовое FAP-приложение находится в директории build в скрытой директории .extapps. Наш файл приложения называется example_1.fap.

ws_code_3

Используя программу qFlipper, перенесём файл приложения на SD-карту в директорию /apps/Misc. Файл можно перенести мышкой прямо в окно программы.

qflipper_2

После последнего обновления все приложения FAP можно сбилдить и перенести на Флиппер одной командой из консоли:

./fbt fap_deploy

Готово! Теперь наше приложение появилось на Флиппере в разделе Misc:

flipper_2

Главная функция example_1_app() сейчас пуста, поэтому при запуске приложения программа просто завершит свою работу и мы не увидим никаких изменений на экране.

Как видно, ваше приложение может вовсе не иметь графического интерфейса и отработать как бы «за кулисами». Но у нашего Флиппера есть экранчик, поэтому нельзя не сделать графический интерфейс.

Пример 2. Графический интерфейс

Добавим нашему приложению графический интерфейс.

Чтобы не путаться между пунктами статьи, мы сделаем новое приложение с именем example_2, но по сути будем продолжать предыдущее приложение.

Новое приложение поместим в директорию applications_user/example_2. Соответственно, файл с исходным кодом приложения имеет имя example_2_app.c.

Для создания графического интерфейса вам придётся значительно расширить исходный код приложения. Создадим в директории приложения заголовочный файл example_2_app.h для описания типов данных, структур и прототипов функций.

Включим уже знакомый нам заголовочный файл ядра furi.h. Для графического интерфейса понадобится заголовок gui/gui.h.

#pragma once

#include <furi.h>
#include <gui/gui.h>

struct Example2App {
    Gui* gui;
    ViewPort* view_port;
};

typedef struct Example2App Example2App;

В заголовочном файле мы создали структуру Example2App, которая будет хранить указатели на все важные компоненты нашего приложения, и ввели новый тип для этой структуры. В структуре нашего приложения есть указатели на графический интерфейс Gui и на ViewPort. ViewPort — это структура, которая используется для отрисовки единичного полного экрана. К ней привязываются указатели на callback-функции отрисовки графических объектов на экране и функции обработки различных событий (Events), например нажатие клавиш.

Исходный код приложения в файле example_2_app.c теперь выглядит так:

#include "example_2_app.h"

#include <furi.h>
#include <gui/gui.h>

Example2App* example_2_app_alloc() {
    Example2App* app = malloc(sizeof(Example2App));

    app->view_port = view_port_alloc();

    app->gui = furi_record_open(RECORD_GUI);
    gui_add_view_port(app->gui, app->view_port, GuiLayerFullscreen);

    return app;
}

void example_2_app_free(Example2App* app) {
    furi_assert(app);

    view_port_enabled_set(app->view_port, false);
    gui_remove_view_port(app->gui, app->view_port);
    view_port_free(app->view_port);

    furi_record_close(RECORD_GUI);
}

int32_t example_2_app(void *p) {
    UNUSED(p);
    Example2App* app = example_2_app_alloc();

    furi_delay_ms(10000);

    example_2_app_free(app);
    return 0;
}

Разберёмся, что есть что. Программирование под Флиппер очень требовательно к ресурсам операционной системы, и нам нужно за этим следить. Это необходимо, чтобы наше приложение работало правильно и не вызывало зависаний и глюков. Поэтому, если мы хотим что-то добавить в приложение или интерфейс, то самостоятельно выделяем под это память, а когда удаляем — освобождаем.

Описываем функцию, которая будет выделять память под структуру нашего приложения и инициализировать его:

Example2App* example_2_app_alloc()

И функцию, которая освобождает занятую приложением память:

void example_2_app_free(Example2App* app)

В функции выделения памяти мы сначала выделяем память под структуру app типа Example2App для нашего приложения. Затем выделяем память для view_port:

app->view_port = view_port_alloc();

Получаем указатель на текущий Gui Флиппера — gui. Перехватываем управление Gui и говорим операционной системе, что у нашего приложения есть некий интерфейс c отрисовкой экрана view_port и мы хотим его отобразить.

app->gui = furi_record_open(RECORD_GUI);
gui_add_view_port(app->gui, app->view_port, GuiLayerFullscreen);

В функции освобождения памяти мы, соответственно, работаем в обратном направлении. Выключаем рендер нашего view_port:

view_port_enabled_set(app->view_port, false);

Отключаем view_port от Gui и освобождаем память, занятую view_port:

gui_remove_view_port(app->gui, app->view_port);
view_port_free(app->view_port);

В конце мы передаём управление графическим интерфейсом от нашего приложения обратно операционной системе:

furi_record_close(RECORD_GUI);

Так как мы пишем на С и регулярно используем указатели, в ядре FURI существует крайне полезная для нас функция furi_assert(), которая экстренно остановит работу приложения и выдаст сообщение, если мы вдруг передадим указатель на пустой участок памяти.

Точкой входа приложения на этот раз будет функция с именем example_2_app:

int32_t example_2_app(void *p) {
    UNUSED(p);
    Example2App* app = example_2_app_alloc();

    furi_delay_ms(10000);

    example_2_app_free(app);
    return 0;
}

При запуске приложения мы аллоцируем память для структуры приложения, перехватываем управление Gui и рендерим наш ViewPort. После этого мы простаиваем 10 секунд функцией furi_delay_ms(), освобождаем все занятые ресурсы, передаём управление Gui операционной системе и завершаем работу приложения.

Кроме того, у нас получился хороший шаблон с выделением/освобождением памяти для будущих приложений.

Иконку для приложения оставляем прежней.

Вносим правки в файл манифеста приложения application.fam. Здесь всё остаётся прежним, за исключением нового параметра requires. В этом параметре мы указываем, что для работы нашему приложению нужен сервис, отвечающий за графические интерфейсы gui.

App(
    appid="example_2",
    name="Example 2 application",
    apptype=FlipperAppType.EXTERNAL,
    entry_point="example_2_app",
    cdefines=["APP_EXAMPLE_2"],
    requires=[
        "gui",
    ],
    stack_size=1 * 1024,
    order=90,
    fap_icon="emoji_smile_icon_10x10px.png",
    fap_category="Misc",
)

Собираем новое приложение:

./fbt fap_example_2

Используя программу qFlipper, перенесём новое FAP-приложение из папки build на SD-карту в папку /apps/Misc. Или используем терминал и команду ./fbt fap_deploy.

Находим новое приложение в списке и запускаем его:

flipper_3

Сейчас для нашего ViewPort не описаны конкретные функции отрисовки графических объектов. Программа приложения просто отобразит пустой графический интерфейс в течение 10 секунд и завершит работу.

Пример 3. Текст и изображения

Добавим в интерфейс нашего приложения какие-нибудь графические объекты. Некоторые графические объекты могут иметь довольно сложную реализацию, например: меню, выпадающие списки, файловые браузеры, различные формы ввода. Мы же начнём с простых объектов.

Снова создадим копию предыдущего приложения, но теперь под именем example_3.

Для рендера графических объектов создадим в исходном файле example_3_app.c callback-функцию отрисовки example_3_app_draw_callback:

static void example_3_app_draw_callback(Canvas* canvas, void* ctx) {
    UNUSED(ctx);
    canvas_clear(canvas);
}

Callback-функция имеет определённую сигнатуру и два аргумента canvas, то есть «холст», на котором мы будем рисовать, и контекст ctx. Контекстом могут быть другие данные, в зависимости от которых рендерится canvas. Пока что мы оставим контекст неиспользованным, то есть UNUSED.

Первым делом перед рендером очистим наш экран:

canvas_clear(canvas);

Графические объекты размещаются на экране согласно системе координат. Экран Флиппера имеет разрешение 128×64, а начало координат находится в левом верхнем углу:

canvas

Добавим текст в интерфейс нашего приложения.

Подключим заголовочный файл с графическими текстовыми элементами gui/elements.h. Текст отрисовывается функцией canvas_draw_str() с указанием координаты (x; y) исходной точки текста и, собственно, самой строки. Возможен выбор шрифта для текста. Шрифт устанавливается функцией canvas_set_font(). Шрифт может быть главным — FontPrimary (высота 8 пикселей), второстепенным — FontSecondary (высота 7 пикселей), FontKeyboard или FontBigNumbers. При желании вы сможете создать и собственный шрифт.

Например, напишем главным шрифтом строку «This is an example app!» вверху экрана и примерно по центру, по координатам (4; 8). По умолчанию начало координат текста находится в левом нижнем углу.

canvas_set_font(canvas, FontPrimary);
canvas_draw_str(canvas, 4, 8, "This is an example app!");

В заголовочном файле gui/elements.h можно найти различные имплементации для отрисовки простых элементов, скроллбаров, кнопок или выравнивания текста.

Например, снизу от нашей первой надписи разместим длинный двухстрочный текст «Some long long long long aligned multiline text», написанный второстепенным шрифтом и автоматически выровненный по верхней и правой границам. Для этого воспользуемся функцией elements_multiline_text_aligned():

canvas_set_font(canvas, FontSecondary);
elements_multiline_text_aligned(canvas, 127, 15, AlignRight, AlignTop, "Some long long long long \n aligned multiline text");

Теперь разберёмся, как вывести на экран картинку.

Для эксперимента мы нарисовали чёрно-белый логотип Амперки в PNG разрешением в 128×35 пикселей:

amperka_ru_logo_128x35px

В папке с вашим приложением создайте специальную директорию для хранения изображений. Например, мы назвали свою папку images. В данную папку поместите все изображения, которые планируете выводить на экран. Мы назвали нашу картинку amperka_ru_logo_128x35px.png и поместили её в созданную папку images:

ws_code_4

В манифесте приложения application.fam добавляем новый параметр fap_icon_assets с путём до директории с изображениями:

App(
    appid="example_3",
    name="Example 3 application",
    apptype=FlipperAppType.EXTERNAL,
    entry_point="example_3_app",
    cdefines=["APP_EXAMPLE_3"],
    requires=[
        "gui",
    ],
    stack_size=1 * 1024,
    order=90,
    fap_icon="emoji_smile_icon_10x10px.png",
    fap_category="Misc",
    fap_icon_assets="images",
)

Теперь при сборке приложения все изображения из папки images будут переведены в код, а сам код будет сгенерирован в специальном заголовочном файле с именем {APPID}_icons.h, где {APPID} — это ID, указанный в .fam-файле манифеста приложения.

Наше приложение имеет ID example_3, значит заголовочный файл получит имя example_3_icons.h. Добавим данный файл в заголовок приложения:

#include "example_3_icons.h"

Теперь мы можем получить указатель на область памяти, где хранится наше изображение в виде массива байтов. Имя указателя будет соответствовать имени самого файла изображения, но с приставкой I_ИМЯ_ВАШЕГО_ФАЙЛА. Для нашей картинки с именем amperka_ru_logo_128x35px.png имя указателя будет I_amperka_ru_logo_128x35px.

Теперь, имея адрес изображения, мы можем отрендерить его на экране функцией canvas_draw_icon(). Выведем изображение внизу экрана по координатам (0; 29):

canvas_draw_icon(canvas, 0, 29, &I_amperka_ru_logo_128x35px);

Пока что хватит графических элементов. Результат должен выглядеть следующим образом:

flipper_4

Наша callback-функция отрисовки готова, и её необходимо привязать к структуре ViewPort нашего приложения. Это нужно сделать в функции инициализации нашего приложения сразу после выделения памяти под ViewPort. В качестве контекста мы ничего не отправляем (NULL). После привязки первый раз callback-функция будет выполнена автоматически.

view_port_draw_callback_set(app->view_port, example_3_app_draw_callback, NULL);

Заголовочный файл нашего третьего приложения не изменился, за исключением имени приложения и подключаемого заголовка с изображениями.

Код example_3_app.h:

#pragma once

#include <furi.h>
#include <gui/gui.h>

#include "example_3_icons.h"

struct Example3App {
    Gui* gui;
    ViewPort* view_port;
};

typedef struct Example3App Example3App;

Код example_3_app.с:

#include "example_3_app.h"

#include <furi.h>
#include <gui/gui.h>

#include <gui/elements.h>

static void example_3_app_draw_callback(Canvas* canvas, void* ctx) {
    UNUSED(ctx);

    canvas_clear(canvas);

    canvas_draw_icon(canvas, 0, 29, &I_amperka_ru_logo_128x35px);

    canvas_set_font(canvas, FontPrimary);
    canvas_draw_str(canvas, 4, 8, "This is an example app!");

    canvas_set_font(canvas, FontSecondary);
    elements_multiline_text_aligned(canvas, 127, 15, AlignRight, AlignTop, "Some long long long long \n aligned multiline text");
}

Example3App* example_3_app_alloc() {
    Example3App* app = malloc(sizeof(Example3App));

    app->view_port = view_port_alloc();

    view_port_draw_callback_set(app->view_port, example_3_app_draw_callback, NULL);

    app->gui = furi_record_open(RECORD_GUI);
    gui_add_view_port(app->gui, app->view_port, GuiLayerFullscreen);

    return app;
}

void example_3_app_free(Example3App* app) {
    furi_assert(app);

    view_port_enabled_set(app->view_port, false);
    gui_remove_view_port(app->gui, app->view_port);
    view_port_free(app->view_port);

    furi_record_close(RECORD_GUI);
}

int32_t example_3_app(void *p) {
    UNUSED(p);
    Example3App* app = example_3_app_alloc();

    furi_delay_ms(10000);

    example_3_app_free(app);
    return 0;
}

Главную функцию приложения example_3_app оставляем как есть. Приложение снова отработает 10 секунд и завершит свою работу, но на этот раз у нас будет графика.

Собираем новое приложение:

./fbt fap_example_3

Используя программу qFlipper, перенесём новое FAP-приложение из папки build на SD-карту в папку /apps/Misc. Или используем терминал и команду ./fbt fap_deploy.

Находим новое приложение в списке и запускаем его:

flipper_5

Взглянем на результат:

Пример 4. Кнопки

Разберёмся, как обрабатывать нажатия кнопок на Флиппере.

Продолжаем предыдущее приложение под новым именем example_4.

Для использования кнопок нам понадобится очередь сообщений (MessageQueue) и ещё одна callback-функция для обработки этой очереди.

Зачем нужна очередь сообщений? Поскольку за нас работает операционная система, то callback-функция ввода с кнопок, как и callback-функция рендера графики выполняется в контексте других потоков ОС Flipper Zero, а не в потоке нашего приложения. Поток, отвечающий за нажатие кнопок, не может вызвать какую-либо функцию напрямую из нашего приложения. Но потоки могут отправлять друг другу сообщения в любой момент выполнения, этим мы и воспользуемся.

Добавляем в структуру нашего приложения Example4App очередь event_queue типа FuriMessageQueue:

struct Example4App {
    Gui* gui;
    ViewPort* view_port;
    FuriMessageQueue* event_queue;
};

В функции инициализации приложения example_4_app_alloc() выделяем память под новую очередь.

app->event_queue = furi_message_queue_alloc(8, sizeof(InputEvent));

Наша очередь будет на 8 сообщений типа InputEvent, который отвечает за нажатие клавиш. Структуры данных и функции для работы с сообщениями от кнопок находятся в заголовочном файле input/input.h.

В функции освобождения памяти нашего приложения, соответственно, освобождаем занятую очередью память.

furi_message_queue_free(app->event_queue);

Теперь создадим callback-функцию для обработки этой очереди.

static void example_4_app_input_callback(InputEvent* input_event, void* ctx) {
    furi_assert(ctx);

    FuriMessageQueue* event_queue = ctx;
    furi_message_queue_put(event_queue, input_event, FuriWaitForever);
}

Как и в случае с callback-функцией отрисовки, эта имеет два аргумента: input_event и контекст ctx. Событие input_event сигнализирует о каком-либо взаимодействии с кнопками. В отличие от функции отрисовки, в этот раз контекст не пустой. Мы положили в контекст очередь сообщений нашего приложения. Таким образом актуальные события ввода с кнопок окажутся в потоке нашего приложения.

Привязываем новую callback-функцию к графическому интерфейсу ViewPort нашего приложения. В качестве контекста указываем очередь сообщений приложения event_queue:

view_port_input_callback_set(app->view_port, example_4_app_input_callback, app->event_queue);

Готово! Теперь информация о состоянии кнопок находится в нашем распоряжении, и её можно обработать.

Сейчас наше приложение работает 10 секунд, а затем завершает работу. Давайте сделаем так, чтобы приложение закрывалось не автоматически, а при нажатии на клавишу «Назад» на Флиппере.

Обработчик сообщений из очереди напишем в главной функции нашего приложения (точке входа) в бесконечном цикле:

while (1) {
    if (furi_message_queue_get(app->event_queue, &event, 100) == FuriStatusOk) {
        if (event.type == InputTypePress) {
            if (event.key == InputKeyBack)
                break;
        }
    }
}

Пока очередь пуста, крутимся в бесконечном цикле. Если в нашей очереди есть событие (FuriStatusOk), нажалась кнопка (InputTypePress), и это была кнопка «Назад» (InputKeyBack), то выходим из цикла и, как следствие, движемся к завершению работы приложения.

Код example_4_app.h:

#pragma once

#include <furi.h>
#include <gui/gui.h>

#include "example_4_icons.h"

struct Example4App {
    Gui* gui;
    ViewPort* view_port;
    FuriMessageQueue* event_queue;
};

typedef struct Example4App Example4App;

Код example_4_app.с:

#include "example_4_app.h"

#include <furi.h>
#include <gui/gui.h>
#include <gui/elements.h>

#include <input/input.h>

static void example_4_app_draw_callback(Canvas* canvas, void* ctx) {
    UNUSED(ctx);

    canvas_clear(canvas);

    canvas_draw_icon(canvas, 0, 29, &I_amperka_ru_logo_128x35px);

    canvas_set_font(canvas, FontPrimary);
    canvas_draw_str(canvas, 4, 8, "This is an example app!");

    canvas_set_font(canvas, FontSecondary);
    elements_multiline_text_aligned(canvas, 127, 15, AlignRight, AlignTop, "Some long long long long \n aligned multiline text");
}

static void example_4_app_input_callback(InputEvent* input_event, void* ctx) {
    furi_assert(ctx);

    FuriMessageQueue* event_queue = ctx;
    furi_message_queue_put(event_queue, input_event, FuriWaitForever);
}

Example4App* example_4_app_alloc() {
    Example4App* app = malloc(sizeof(Example4App));

    app->view_port = view_port_alloc();
    app->event_queue = furi_message_queue_alloc(8, sizeof(InputEvent));

    view_port_draw_callback_set(app->view_port, example_4_app_draw_callback, NULL);
    view_port_input_callback_set(app->view_port, example_4_app_input_callback, app->event_queue);

    app->gui = furi_record_open(RECORD_GUI);
    gui_add_view_port(app->gui, app->view_port, GuiLayerFullscreen);

    return app;
}

void example_4_app_free(Example4App* app) {
    furi_assert(app);

    view_port_enabled_set(app->view_port, false);
    gui_remove_view_port(app->gui, app->view_port);
    view_port_free(app->view_port);

    furi_message_queue_free(app->event_queue);

    furi_record_close(RECORD_GUI);
}

int32_t example_4_app(void *p) {
    UNUSED(p);
    Example4App* app = example_4_app_alloc();

    InputEvent event;

    while (1) {
        if (furi_message_queue_get(app->event_queue, &event, 100) == FuriStatusOk) {
            if (event.type == InputTypePress) {
                if (event.key == InputKeyBack)
                    break;
            }
        }
    }

    example_4_app_free(app);
    return 0;
}

Собираем новое приложение и переносим его на Flipper Zero.

./fbt fap_example_4
./fbt fap_deploy

flipper_6

Взглянем на результат:

Изменим наше приложение и добавим ещё пару кнопок.

Например, будем по-разному рендерить изображение на экране в зависимости от нажатой кнопки.

Пусть у нас будет три состояния интерфейса: рендерится только текст, рендерится только изображение, либо рендерится и то, и другое. Переключаться между этими режимами будем при долгом нажатии на кнопки «Влево» или «Вправо».

Введём в структуру нашего приложения переменную, которая будет отвечать за то, какой режим рендерится в данный момент. Назовем её draw_mode.

typedef enum {
    DRAW_ALL,
    DRAW_ONLY_TEXT,
    DRAW_ONLY_PICTURES,
    TOTAL_DRAW_MODES = 3,
} DrawMode;

struct Example4App {
    Gui* gui;
    ViewPort* view_port;
    FuriMessageQueue* event_queue;

    DrawMode draw_mode;
};

Чтобы draw_mode был доступен для нашей callback-функции рендера экрана, передадим в неё указатель на всю структуру приложения app в качестве контекста:

view_port_draw_callback_set(app->view_port, example_4_app_draw_callback, app);

Теперь изменим саму callback-функцию рендера. Пусть разные графические объекты рендерятся в зависимости от текущего значения draw_mode:

static void example_4_app_draw_callback(Canvas* canvas, void* ctx) {
    furi_assert(ctx);
    Example4App* app = ctx;

    canvas_clear(canvas);

    DrawMode mode = app->draw_mode;
    if (mode == DRAW_ONLY_PICTURES || mode == DRAW_ALL)
        canvas_draw_icon(canvas, 0, 29, &I_amperka_ru_logo_128x35px);
    if (mode == DRAW_ONLY_TEXT|| mode == DRAW_ALL) {
        canvas_set_font(canvas, FontPrimary);
        canvas_draw_str(canvas, 4, 8, "This is an example app!");
        canvas_set_font(canvas, FontSecondary);
        elements_multiline_text_aligned(canvas, 127, 15, AlignRight, AlignTop, "Some long long long long \n aligned multiline text");
    }
}

В заключении обработаем новые события кнопок в бесконечном цикле главной функции приложения:

while (1) {
    if (furi_message_queue_get(app->event_queue, &event, 100) == FuriStatusOk) {
        if (event.type == InputTypePress) {
            if (event.key == InputKeyBack)
                break;
        } else if (event.type == InputTypeLong) {
            DrawMode mode = app->draw_mode;
            if (event.key == InputKeyLeft)
                app->draw_mode = (mode - 1 + TOTAL_DRAW_MODES) % TOTAL_DRAW_MODES;
            else if (event.key == InputKeyRight)
                app->draw_mode = (mode + 1) % TOTAL_DRAW_MODES;

            view_port_update(app->view_port);
        }
    }
}

Теперь если будет зарегистрировано событие длительного нажатия (InputTypeLong) кнопки «Влево» (InputKeyLeft) или «Вправо» (InputKeyRight), наш режим отрисовки app->draw_mode будет меняться от 0 до TOTAL_DRAW_MODES.

Функция view_port_update() запускает ререндер нашего интерфейса view_port. Функция не обязательная, операционная система сама производит ререндер раз в несколько миллисекунд, но мы можем форсировать это функцией.

Соберём обновлённое приложение, загрузим его на Флиппер, запустим и посмотрим результат:

Пример 5. Оповещения

Помимо дисплея Flipper Zero имеет и другой способ сообщать нам о происходящих в программе событиях — оповещения (Notifications). Нам доступно управление следующими встроенными девайсами:

  • RGB-cветодиод.
  • Вибромотор.
  • Пьезопищалка.

Продолжаем предыдущее приложение под новым именем example_5.

За оповещения в операционной системе Flipper Zero отвечает отдельный поток, и мы не можем вызывать его функции из нашего приложения напрямую. Но мы можем отсылать в этот поток сообщения — NotificationMessage. Из этих сообщений формируются последовательности NotificationSequence, которые уже непосредственно отправляются в поток.

Описание структур сообщений и их последовательностей находится в заголовочном файле notification/notification_messages.h, добавляем его в наше приложение.

В главной структуре указываем, что наше приложение собирается использовать оповещения NotificationApp:

struct Example5App {
    Gui* gui;
    ViewPort* view_port;
    FuriMessageQueue* event_queue;
    NotificationApp* notifications;

    DrawMode draw_mode;
};

В функции инициализации приложения example_5_app_alloc() перехватываем управление оповещениями:

app->notifications = furi_record_open(RECORD_NOTIFICATION);

А в функции освобождения памяти приложения example_5_app_free отдаём управление обратно:

furi_record_close(RECORD_NOTIFICATION);

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

Создадим нашу последовательность сообщений для управления RGB-светодиодом. Назовем её example_led_sequence и разместим в заголовке нашего приложения.

Пусть светодиод мигнёт фиолетовым цветом RGB(255, 0, 255) три раза с интервалом 500 мс, а затем погаснет. Сообщение будет выглядеть следующим образом:

const NotificationSequence example_led_sequence = {
    &message_red_255,
    &message_blue_255,
    &message_delay_500,
    &message_red_0,
    &message_blue_0,
    &message_delay_500,
    &message_red_255,
    &message_blue_255,
    &message_delay_500,
    &message_red_0,
    &message_blue_0,
    &message_delay_500,
    &message_red_255,
    &message_blue_255,
    &message_delay_500,
    &message_red_0,
    &message_blue_0,
    NULL,
};

Последовательности сообщений для управления вибромотором составляются схожим образом. Создадим последовательность для вибромотора с именем example_vibro_sequence и разместим её в заголовке.

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

const NotificationSequence example_vibro_sequence = {
    &message_vibro_on,
    &message_do_not_reset,
    &message_delay_1000,
    &message_delay_1000,
    &message_delay_1000,
    &message_vibro_off,
    NULL,
};

По умолчанию максимально долгая описанная задержка составляет 1 секунду, поэтому мы три раза использовали сообщение message_delay_1000.

Теперь создадим последовательность сообщений для пьезодинамика. Назовем её example_sound_sequence.

Здесь нам уже доступна полная MIDI-клавиатура прямо из коробки! Описание всех нот и их частот можно посмотреть в заголовочном файле notification_messages_notes.h.

Добавим в наш Флиппер классическую мелодию звонка телефонов Nokia:

sound

Последовательность сообщений с данной мелодией выглядит так:

const NotificationSequence example_sound_sequence = {
    &message_note_e5,
    &message_delay_100,
    &message_note_d5,
    &message_delay_100,
    &message_note_fs4,
    &message_delay_250,
    &message_note_gs4,
    &message_delay_250,
    &message_note_cs5,
    &message_delay_100,
    &message_note_b4,
    &message_delay_100,
    &message_note_d4,
    &message_delay_250,
    &message_note_e4,
    &message_delay_250,
    &message_note_b4,
    &message_delay_100,
    &message_note_a4,
    &message_delay_100,
    &message_note_cs4,
    &message_delay_250,
    &message_note_e4,
    &message_delay_250,
    &message_note_a4,
    &message_delay_500,
    NULL,
};

Отлично! Теперь нужно решить, когда запускать данные оповещения. Пусть при нажатии на кнопку «Вверх» (InputKeyUp) включится светодиод, при нажатии на кнопку «Вниз» (InputKeyDown) включится вибромотор, а при нажатии на кнопку «Ок» (InputKeyOk) заиграет мелодия.

Добавляем обработку для новых кнопок в бесконечный цикл в главной функции нашего приложения example_5_app():

while (1) {
    if (furi_message_queue_get(app->event_queue, &event, 100) == FuriStatusOk) {
        if (event.type == InputTypePress) {
            if (event.key == InputKeyBack)
                break;
            else if (event.key == InputKeyUp)
                notification_message(app->notifications, &example_led_sequence);
            else if (event.key == InputKeyDown)
                notification_message(app->notifications, &example_vibro_sequence);
            else if (event.key == InputKeyOk)
                notification_message(app->notifications, &example_sound_sequence);

        } else if (event.type == InputTypeLong) {
            DrawMode mode = app->draw_mode;
            if (event.key == InputKeyLeft)
                app->draw_mode = (mode - 1 + TOTAL_DRAW_MODES) % TOTAL_DRAW_MODES;
            else if (event.key == InputKeyRight)
                app->draw_mode = (mode + 1) % TOTAL_DRAW_MODES;

            view_port_update(app->view_port);
        }
    }
}

Отправка сообщений осуществляется функцией notification_message() с указанием соответствующей последовательности.

Собираем новое приложение:

./fbt fap_example_5

Используя программу qFlipper, переносим новый FAP-файл из папки build на SD-карту в папку /apps/Misc. Или загружаем приложение командой ./fbt fap_deploy.

Запускаем приложение:

flipper_7

Смотрим на результат:

Пример 6. GPIO

На Flipper Zero 18 контактов GPIO, среди которых есть как пины питания, так и пины ввода-вывода. Логическое напряжение питания — 3,3 В, и пины нетолерантны к 5 В (за исключением пина iButton). По сути, пины Флиппера соответствуют пинам установленного в нём микроконтроллера STM32WB55 и обладают теми же настраиваемыми альтернативными функциями (ADC, USART, SPI и др.).

Распиновка Флиппера:

pinout

Подробное назначение пинов можно посмотреть в заголовочном файле furi_hal_resources.h. FURI HAL — специальный HAL Flipper Zero, который призван упростить для нас взаимодействие с железом.

В FURI HAL GPIO-структура имеет имя GpioPin. Без разборки Флиппера на куски нам доступны:

  • const GpioPin gpio_ext_pc0 — порт GPIOC, пин 0 (номер 16 на Флиппере).
  • const GpioPin gpio_ext_pc1 — порт GPIOC, пин 1 (номер 15 на Флиппере).
  • const GpioPin gpio_ext_pc3 — порт GPIOC, пин 3 (номер 7 на Флиппере).
  • const GpioPin gpio_ext_pb2 — порт GPIOB, пин 2 (номер 6 на Флиппере).
  • const GpioPin gpio_ext_pb3 — порт GPIOB, пин 3 (номер 5 на Флиппере).
  • const GpioPin gpio_ext_pa4 — порт GPIOA, пин 4 (номер 4 на Флиппере).
  • const GpioPin gpio_ext_pa6 — порт GPIOA, пин 6 (номер 3 на Флиппере).
  • const GpioPin gpio_ext_pa7 — порт GPIOA, пин 7 (номер 2 на Флиппере).
  • const GpioPin ibutton_gpio — порт GPIOB, пин 14 (номер 17 на Флиппере).

Также доступны пины с функцией USART по умолчанию:

  • const GpioPin gpio_usart_tx — порт GPIOB, пин 6 (номер 13 на Флиппере).
  • const GpioPin gpio_usart_rx — порт GPIOB, пин 7 (номер 14 на Флиппере).

Ещё есть пины интерфейса SWD (Serial Wire Debug) для отладки, маркированные на корпусе как SIO, SWC. Все остальные пины микроконтроллера используются для управления начинкой Flipper Zero: дисплеем, кнопками, USB, NFC, I²C, SPI и т. д.

Сделаем приложение, через которое мы сможем управлять GPIO. Сперва сделаем простой DigitalWrite, DigitalRead. Читать значение будем с пина А6, а писать значение в пин А7.

Подключим к Флипперу простую кнопку к пину А6 и светодиод к пину А7. Максимальный ток на пине — 20 мА, для светодиода хватит. Питание берём с шины 3,3 В. Установим светодиод и кнопку на макетную плату:

breadboard_1

Назовём новое приложение example_6 и сделаем его на основе нашего предыдущего примера номер 4. Предварительно уберём из приложения всё, что касается оповещений, рендера графических элементов и обработки кнопок, чтобы остался пустой интерфейс.

Для управления GPIO нам нужен HAL Флиппера. Подключаем файл furi_hal.h в заголовок нашего приложения.

В главной структуре Example6App нашего приложения создадим два пина для входа и выхода: input_pin, output_pin и две булевы переменные для хранения текущих значений на этих пинах: input_value, output_value.

Наш заголовочный файл принимает следующий вид:

#pragma once

#include <furi.h>
#include <furi_hal.h>
#include <gui/gui.h>

struct Example6App {
    Gui* gui;
    ViewPort* view_port;
    FuriMessageQueue* event_queue;

    const GpioPin* input_pin;
    const GpioPin* output_pin;

    bool input_value;
    bool output_value;
};

typedef struct Example6App Example6App;

В функции инициализации приложения example_6_app_alloc() задаём номера пинов. Функцией furi_hal_gpio_init() инициалзируем пины. Для ввода устанавливаем режим GpioModeInput и включаем подтяжку GpioPullUp, а для вывода режим GpioModeOutputPushPull и отключаем подтяжку GpioPullNo. Оба пина опрашиваются на максимальной скорости GpioSpeedVeryHigh:

app->input_pin = &gpio_ext_pa6;
app->output_pin = &gpio_ext_pa7;

furi_hal_gpio_init(app->input_pin, GpioModeInput, GpioPullUp, GpioSpeedVeryHigh);
furi_hal_gpio_init(app->output_pin, GpioModeOutputPushPull, GpioPullNo, GpioSpeedVeryHigh);

В главной функции приложения example_6_app() (и по совместительству в нашей точке входа) считываем и отправляем соответствующие значения в бесконечном цикле:

furi_hal_gpio_write(app->output_pin, app->output_value);
app->input_value = furi_hal_gpio_read(app->input_pin);

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

if (furi_message_queue_get(app->event_queue, &event, 100) == FuriStatusOk) {
    if (event.key == InputKeyBack) {
        if (event.type == InputTypePress)
            break;

    } else if (event.key == InputKeyOk) {
        if (event.type == InputTypePress)
            app->output_value = true;
        else if (event.type == InputTypeRelease)
            app->output_value = false;
    }

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

static void example_6_app_draw_callback(Canvas* canvas, void* ctx) {
    furi_assert(ctx);
    Example6App* app = ctx;

    canvas_clear(canvas);
    canvas_set_font(canvas, FontSecondary);
    elements_multiline_text_aligned(canvas, 32, 17, AlignCenter, AlignTop, "Output PA7:");
    elements_multiline_text_aligned(canvas, 96, 17, AlignCenter, AlignTop, "Input PA6:");

    canvas_set_font(canvas, FontBigNumbers);
    elements_multiline_text_aligned(canvas, 32, 32, AlignCenter, AlignTop, app->output_value ? "1" : "0");
    elements_multiline_text_aligned(canvas, 96, 32, AlignCenter, AlignTop, app->input_value ? "1" : "0");
}

Интерфейс будет выглядеть так:

flipper_8

Собираем новое приложение и загружаем его на Флиппер:

./fbt fap_example_6
./fbt fap_deploy

Протестируем приложение на Флиппере:

PWM и ADC

С генерацией ШИМ-сигналов всё обстоит намного сложнее. Здесь уже не обойтись одной-двумя функциями из FURI HAL, а сам код сильно разрастается.

В прошивке Flipper Zero уже есть приложение Signal Generator для генерации ШИМ-сигнала на пинах PA7 и PA4. Вы можете самостоятельно изучить исходный код для генерации ШИМ в директории applications/plugins/signal_generator/ и реплицировать его в ваше приложение.

А вот официальной документации на чтение аналоговых сигналов пока нет. Кроме этого альтернативные функции аналого-цифрового преобразователя на пинах ещё не имплементированы в FURI HAL. Однако сам ADC на микроконтроллере STM32 и его возможности никуда от нас не делись.

На просторах интернета мы нашли пример использования ADC. Мы разместили в репозитории с примерами flipperzero-examples приложение adc_example, которое cчитывает аналоговое значение с пина PC3 и выводит его на экран. Код ещё нуждается в доработке, и вы можете использовать его в своих приложениях, однако мы советуем дождаться официальной документации и примеров.

Опорным напряжением является выборочно или 2.5 В или 2.048 В. Подключив к флипперу потенциометр и взяв питание с пина 3.3 В понадобится простой делитель напряжения в пределах тысячи Ом. Разрешение АЦП - 12 бит.

Читаем напряжение от 0 до 2.5В на пине PC3 и меняем его потенциометром:

Заключение

На этом мы заканчиваем базовое знакомство с пользовательскими приложениями для Flipper Zero.

Покопавшись в документации, нам удалось зайти подальше банального «Hello, world!» и написать несколько приложений для примера работы с GUI, кнопками и встроенной периферией Флиппера — RGB-светодиодом, вибромотором и баззером.

Ждём обновления официальной документации гаджета и надеемся, что наши примеры помогут вам в создании своих приложений для Flipper Zero!

Полезные ссылки