NFC-крокодил: продвинутая разработка под Flipper Zero

в 11:45, , рубрики: C, flipper zero, NFC, Блог компании Selectel, крокодил, ненормальное программирование, реверс-инжиниринг
NFC-крокодил: продвинутая разработка под Flipper Zero - 1

Мое желание делать игры с использованием различных неподходящих для этого технологий только прогрессирует. Мне показалась забавной идея реализовать игру «крокодил», где нужно объяснять слова через пантомиму, с использованием технологии NFC. При считывании метки выбирает случайное слово из памяти и отдает его телефону. Игрок видит слово на своем телефоне и начинает его показывать. К сожалению, существующие и легкодоступные NFC-метки не умеют существенно изменять содержимое без команды перезаписи.

Недавно коллеги из Selectel одолжили мне Flipper Zero, который может прикидываться NFC-меткой с любым содержимым. В интернете множество материалов о Flipper Zero. Обзор, еще один обзор, инструкция, как настроить IDE для разработки приложений, обзор внутренностей, быстрый старт и первое приложение…

В этой статье я расскажу про подход к организации GUI-приложений на Flipper Zero, про работу с NFC и NDEF, а также о том, как не бояться отсутствия документации в активно развивающемся проекте.

О закулисье разработки этого и других своих проектов рассказываю в личном Telegram-канале. Уверен, флиппер еще не раз станет героем моих постов.

Для удобства чтения воспользуйтесь навигацией:

Проект
Создание приложения
Графический интерфейс
Чтение с накопителя
Работа с NFC
NDEF-сообщения
Дополнительное чтение

Проект


Идея очередной «игры в слова» уместилась в первый абзац вступления. Основной «фишкой» мне виделась оффлайн-магия NFC-меток: загаданное слово передается на телефон, а телефону для отображения не нужны дополнительные приложения. Я рассматривал несколько возможных способов.

Готовая NFC-метка. Я искал программируемые NFC-метки, у которых можно переопределять логику. Вроде бы такие существуют, но их программируют сразу на заводе, так что не мой вариант.

Телефон. Сейчас уже никого не удивишь оплатой с помощью телефона. Это значит, что множество телефонов умеет эмулировать банковские карты. Здесь, правда, тоже ловить нечего. Хотя NFC-чип в большинстве телефонов может эмулировать NFC-карты, с точки зрения операционной системы эта функциональность ограничена эмуляцией платежных карт. Снять ограничение можно только джейлбрейком root-доступом, но это явно перебор для игры.

DIY. Всегда есть вариант сделать что-то самому, но я не так силен в схемотехнике, чтобы сделать метку своими руками. Поэтому этот вариант так и остался за кадром.

NFC-крокодил: продвинутая разработка под Flipper Zero - 2

Онлайн-версия

Пока я ходил вокруг да около, мои коллеги из event-отдела независимо сделали онлайн-версию NFC-крокодила. В комплекте присутствуют три карточки с разным уровнем сложности слов. При считывании метки открывается страница в браузере, а на странице появляется слово для игры. Обновление страницы приводит к обновлению слова. При этом необходимость в карточках пропадает.

Когда я получил Flipper Zero для экспериментов, мой первый вопрос был «Может ли он эмулировать NFC-карты?». Получив утвердительный ответ, я приступил к реализации своей игровой задумки. Вот такие требования я поставил к приложению:

  • Оно должно позволять выбирать один из загруженных словарей слов. При этом словарь должен быть в текстовом формате для удобства редактирования и создания пользовательских словарей.
  • Интерфейс приложения должен быть на английском языке, при этом язык словаря может быть любым.

Архитектурно это разбивает проект на три задачи поменьше.

  1. Сделать графический интерфейс. Желательно использовать средства ОС, а не рисовать все представления самому.
  2. Извлекать словарь с флешки и выбирать в нем случайное слово.
  3. Создавать виртуальную NFC-метку со словом из предыдущего пункта.

Секцию с настройкой IDE я пропущу, так как по этой теме уже есть статьи. Я работал в таких условиях:

  • ОС: Windows 10.
  • IDE: VSCode (мне не понравилось).
  • Flipper Zero FZ.1, FW: 0.86.2.
  • Отладочная плата ST-Link, часть Nucleo F429ZI. Только UART для чтения логов, SWDIO и отладчик не использовал.
  • NFC-читалка: Realme 8.
  • Компиляция приложения через fbt.

Начинаем.

NFC-крокодил: продвинутая разработка под Flipper Zero - 3

Создание приложения

NFC-крокодил: продвинутая разработка под Flipper Zero - 4

Источник

На данный момент официальной документации по созданию приложений не найти. Объяснение этому — постоянные изменения в API. Документацию обещают к выходу прошивки со стабильным интерфейсом, то есть к версии 1.0. Дорожную карту развития проекта можно найти в Miro, и там релиз 1.0.0 был намечен еще на 2022Q3, но он немного задерживается.

При этом отсутствие выделенной документации не так страшно: проект flipper-firmware предоставляет список доступных приложению функций, комментарии в заголовочных файлах, также дополнительно можно найти примеры использования в существующих приложениях.

В качестве эталона я рассматриваю приложения, которые уже являются частью прошивки Flipper Zero. Эти подходы используют в официальных приложениях, значит, и нам так нужно.

Для сборки приложений можно использовать два подхода:

  1. Положить каталог с приложением в директорию application_user репозитория flipperdevices/flipperzero-firmware и компилировать с помощью утилиты fbt.
  2. Собирать приложение отдельно утилитой ufbt.

Я использовал первый вариант. Далее создаем описание приложения в файле application.fam:

App(
    appid="nfc_crocodile",
    name="NFC Crocodile",
    apptype=FlipperAppType.EXTERNAL,
    entry_point="nfc_crocodile_main",
    stack_size=1 * 1024,
    order=90,
    fap_category="Games",
    requires=[
        "storage",
        "gui"
    ],
    fap_icon_assets="assets"
)

Здесь ничего нового или необычного. Указываем, что это внешнее приложение, задаем категорию, определяем каталог с картинками. Для графики и обращения к SD-карте требуется дополнительная секция requires, которая линкует наше приложение с дополнительными модулями. Обратите внимание, что здесь нужно перечислять сервисы, которые располагаются в applications/services. То есть библиотеку для работы с NFC указывать не надо.

Для разработки используем язык программирования С. Да, С++ поддерживается, но основные приложения написаны на С. Отсюда появляется первая практика, которая встречается практически в каждой строке исходного кода любого приложения. Для разделения на «пространство имен» используется префикс с именем. Например, мое приложение называется NFC Crocodile, а значит, все файлы и методы будут начинаться со слов nfc_crocodile, а все типы данных — с NfcCrocodile.

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

# Публичные функции и типы, которые используются другими частями приложения
nfc_crocodile_worker.h

# Приватные функции и типы
nfc_crocodile_worker_i.h

# Реализация функций
nfc_crocodile_worker.c

Множество методов в ОС Flipper Zero любят обращаться к функциям обратного вызова (callback) и передавать туда некоторый context. Поэтому при создании приложения создаем структуру, которая описывает состояние приложения и передаем эту структуру везде где только можно.

Файл nfc_crocodile.h

#include <furi.h>
typedef struct NfcCrocodile {
    // Здесь будут наши данные
} NfcCrocodile;

Файл nfc_crocodile.c

NfcCrocodile* nfc_crocodile_alloc() {
    NfcCrocodile* context = (NfcCrocodile*)malloc(sizeof(NfcCrocodile));
    return context;
}
void nfc_crocodile_free(NfcCrocodile* context) {
    furi_assert(context);
    free(context);
}
int32_t nfc_crocodile_main(void* p) {
    UNUSED(p);
    NfcCrocodile* context = nfc_crocodile_alloc();

    // Здесь будет логика

    nfc_crocodile_free(context);
    return 0;
}

С основами вроде разобрались, переходим к первому этапу — графическому интерфейсу.

Графический интерфейс

NFC-крокодил: продвинутая разработка под Flipper Zero - 5

Всегда есть возможность создать графический интерфейс самостоятельно. Это удобный вариант для «одноэкранных» приложений или игр. Тем не менее, для части приложений нет желания изобретать велосипед, когда можно воспользоваться уже готовым. Операционная система Flipper Zero предлагает множество компонентов и менеджер сцен (SceneManager), который позволяет управлять сложным приложением.

Для создания графического интерфейса необходимы:

  • SceneManager.
  • ViewDispatcher.
  • Gui.
  • Все нужные приложению экраны (о них далее).

Заполняем структуру приложения.

// В начало файла
#include <gui/gui.h>
#include <gui/view_dispatcher.h>
#include <gui/scene_manager.h>
#include <gui/modules/submenu.h>
#include <gui/modules/popup.h>
#include <gui/modules/dialog_ex.h>
#include <gui/modules/variable_item_list.h>

// Создаем перечисление
typedef enum {
    NfcCrocodileViewMenu,
    NfcCrocodileViewPopup,
    NfcCrocodileViewVariableItemList,
    NfcCrocodileViewDialog
} NfcCrocodileView;

// Дополняем структуру
typedef struct NfcCrocodile {
    // GUI
    Gui* gui;
    SceneManager* scene_manager;
    ViewDispatcher* view_dispatcher;
    Submenu* submenu;
    Popup* popup;
    VariableItemList* variable_item_list;
    DialogEx* dialog;
} NfcCrocodile;

В своем приложении я использовал не все компоненты, а лишь те, которые подходят моей задаче. Каждый компонент должен быть вписан в перечисление, это позднее будет использовано в менеджере сцен. Порядок не имеет значение.

В корне проекта создаем каталог scenes — там будет жить исходный код каждой сцены. Они описываются четырьмя элементами.

  1. Номер сцены. Для удобства управления номер скрывается за именованным перечислением.
  2. Обработчик события on_enter. Обработчик вызывается при переходе на указанную сцену. Здесь настраиваются отображаемые компоненты.
  3. Обработчик событий внутри сцены.
  4. Обработчик события on_exit. Обработчик вызывается при выходе со сцены. Здесь нужно прибраться за собой.

Для удобства управления сценами в коде других приложений можно встретить препроцессорную магию.

Файл scenes/nfc_crocodile_scene.h

#pragma once
#include <gui/scene_manager.h>

// Generate scene id and total number
#define ADD_SCENE(prefix, name, id) NfcCrocodileScene##id,
typedef enum {
#include "nfc_crocodile_scene_config.h"
    NfcCrocodileSceneNum,
} NfcCrocodileScene;
#undef ADD_SCENE
extern const SceneManagerHandlers nfc_crocodile_scene_handlers;

// Generate scene on_enter handlers declaration
#define ADD_SCENE(prefix, name, id) void prefix##_scene_##name##_on_enter(void*);
#include "nfc_crocodile_scene_config.h"
#undef ADD_SCENE

// Generate scene on_event handlers declaration
#define ADD_SCENE(prefix, name, id) 
    bool prefix##_scene_##name##_on_event(void* context, SceneManagerEvent event);
#include "nfc_crocodile_scene_config.h"
#undef ADD_SCENE

// Generate scene on_exit handlers declaration
#define ADD_SCENE(prefix, name, id) void prefix##_scene_##name##_on_exit(void* context);
#include "nfc_crocodile_scene_config.h"
#undef ADD_SCENE

Заголовочный файл nfc_crocodile_scene.h четыре раза переопределяет макрос ADD_SCENE и тем самым создает перечисление сцен (enum), а также объявляет по три обработчика для каждой сцены.

Файл scenes/nfc_crocodile_scene.c

#include "nfc_crocodile_scene.h"

// Generate scene on_enter handlers array
#define ADD_SCENE(prefix, name, id) prefix##_scene_##name##_on_enter,
void (*const nfc_crocodile_on_enter_handlers[])(void*) = {
#include "nfc_crocodile_scene_config.h"
};
#undef ADD_SCENE

// Generate scene on_event handlers array
#define ADD_SCENE(prefix, name, id) prefix##_scene_##name##_on_event,
bool (*const nfc_crocodile_on_event_handlers[])(void* context, SceneManagerEvent event) = {
#include "nfc_crocodile_scene_config.h"
};
#undef ADD_SCENE

// Generate scene on_exit handlers array
#define ADD_SCENE(prefix, name, id) prefix##_scene_##name##_on_exit,
void (*const nfc_crocodile_on_exit_handlers[])(void* context) = {
#include "nfc_crocodile_scene_config.h"
};
#undef ADD_SCENE

// Initialize scene handlers configuration structure
const SceneManagerHandlers nfc_crocodile_scene_handlers = {
    .on_enter_handlers = nfc_crocodile_on_enter_handlers,
    .on_event_handlers = nfc_crocodile_on_event_handlers,
    .on_exit_handlers = nfc_crocodile_on_exit_handlers,
    .scene_num = NfcCrocodileSceneNum,
};

Аналогичная магия повторяется в nfc_crocodile_scene.c. Только в этот раз объявленные ранее функции собираются в массивы, а массивы и перечисление сцен, в свою очередь, становится частью структуры SceneManagerHandlers. Для добавления сцены в проект достаточно добавить одну строку в файл «конфигурации».

Файл scenes/nfc_crocodile_scene_config.h

ADD_SCENE(nfc_crocodile, main_menu, MainMenu)

Такой подход создания сцен существенно облегчает жизнь разработчику.

  • Имена функций-обработчиков всегда создаются по одному шаблону.
  • Структура SceneManagerHandlers требует, чтобы функция под номером i в каждом обработчике относилась к сцене под номером i.

Теперь можем описать сцену. В моем случае это будет главное меню, то есть список с тремя пунктами: запустить игру, настройки и «О программе». Создаем файл scenes/nfc_crocodile_scene_main_menu.c и приступаем к реализации всех обработчиков.

#include "../nfc_crocodile_i.h"
#include "nfc_crocodile_scene.h"

// Для меню тоже нужны перечисления ;)
enum {
    NfcCrocodileSceneMainMenuDictionaryListItem,
    NfcCrocodileSceneMainMenuSettingsItem,
    NfcCrocodileSceneMainMenuAboutItem,
} NfcCrocodileSceneMainMenuItems;

// Функция вызывается при выборе опции
void nfc_crocodile_scene_main_menu_choice_callback(void* context, uint32_t index) {
    NfcCrocodile* nfc_crocodile = context;
    // Создаем в менеджере сцен пользовательское событие, 
    // где номер выбранной опции — это номер события
    scene_manager_handle_custom_event(nfc_crocodile->scene_manager, index);
}
void nfc_crocodile_scene_main_menu_on_enter(void* context) {
    NfcCrocodile* nfc_crocodile = context;

    // Добавляем позиции в меню
    submenu_add_item(
        nfc_crocodile->submenu,
        "Start game",  // Название позиции
        NfcCrocodileSceneMainMenuDictionaryListItem, // Индекс. Для callback-функции
        nfc_crocodile_scene_main_menu_choice_callback,
        context
    );

    // ...

    // Отмечаем элемент, на котором мы остановились при выходе из сцены.
    // При первом запуске значение по умолчанию – 0
    submenu_set_selected_item(
        nfc_crocodile->submenu,
        scene_manager_get_scene_state(nfc_crocodile->scene_manager, NfcCrocodileSceneMainMenu)
    );

    // Так как мы готовили Submenu, то передаем диспетчеру команду 
    // вывести этот компонент
    view_dispatcher_switch_to_view(nfc_crocodile->view_dispatcher, NfcCrocodileViewMenu);
}

Компонент Submenu берет на себя задачу отображения меню и навигации с помощью кнопок. При выборе одного из пунктов по нажатию центральной кнопки вызывается функция nfc_crocodile_scene_main_menu_choice_callback, в нее одним из аргументов передается индекс объекта. Эта функция обратного вызова обычно не содержит логики, а создает для менеджера сцены пользовательское событие, которое попадает в соответствующий обработчик.

bool nfc_crocodile_scene_main_menu_on_event(void* context, SceneManagerEvent event) {
    NfcCrocodile* nfc_crocodile = context;
    bool consumed = false;
    if(event.type == SceneManagerEventTypeCustom) {
        // Нажатие инициирует переключение на другую сцену, 
        // так что записываем индекс выбранного пункта в 
        // "состояние" сцены 
        scene_manager_set_scene_state(
            nfc_crocodile->scene_manager, 
            NfcCrocodileSceneMainMenu, // Идентификатор сцены 
            event.event // Номер выбранного пункта
        );

        // В зависимости от выбранного индекса запускаем следующую сцену
        switch(event.event) {
        case NfcCrocodileSceneMainMenuDictionaryListItem:
            scene_manager_next_scene(
                nfc_crocodile->scene_manager, 
                NfcCrocodileSceneDictionary
            );
            consumed = true;
            break;
        case NfcCrocodileSceneMainMenuSettingsItem:
            scene_manager_next_scene(nfc_crocodile->scene_manager, NfcCrocodileSceneSettings);
            consumed = true;
            break;
        }
    }
    return consumed;
}

Использовать перечисления — хороший тон, который позволяет легко читать код. Однако сцена сама генерирует пользовательские события и сама их обрабатывает, так что иногда можно оставлять просто числа.

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

// Обработчик кнопки Back
bool nfc_crocodile_back_event_callback(void* context) {
    furi_assert(context);
    NfcCrocodile* nfc_crocodile = context;
    return scene_manager_handle_back_event(nfc_crocodile->scene_manager);
}

bool nfc_crocodile_custom_event_callback(void* context, uint32_t event) {
    furi_assert(context);
    NfcCrocodile* nfc_crocodile = context;
    return scene_manager_handle_custom_event(nfc_crocodile->scene_manager, event);
}

NfcCrocodile* nfc_crocodile_alloc() {
    NfcCrocodile* context = (NfcCrocodile*)malloc(sizeof(NfcCrocodile));
    furi_assert(context);

    // Аллоцируем менеджера и диспетчера
    context->scene_manager = scene_manager_alloc(&nfc_crocodile_scene_handlers, context);
    context->view_dispatcher = view_dispatcher_alloc();

    // Включаем очередь для событий
    view_dispatcher_enable_queue(context->view_dispatcher);

    // Задаем контекст, чтобы во все обработчики приходил указатель 
    // на состояние нашего приложения
    view_dispatcher_set_event_callback_context(context->view_dispatcher, context);

    // Добавляем обработчик кнопки назад и обработчик пользовательских событий
    view_dispatcher_set_navigation_event_callback(
        context->view_dispatcher, nfc_crocodile_back_event_callback);
    view_dispatcher_set_custom_event_callback(
        context->view_dispatcher, nfc_crocodile_custom_event_callback);

    // GUI
    context->gui = furi_record_open(RECORD_GUI);
    view_dispatcher_attach_to_gui(
        context->view_dispatcher, context->gui, ViewDispatcherTypeFullscreen);

    // Аллоцируем компонент submenu
    context->submenu = submenu_alloc();

    // Указываем диспетчеру на компонент 
    view_dispatcher_add_view(
        context->view_dispatcher, 
        NfcCrocodileViewMenu, // Идентификатор из перечисления 
        submenu_get_view(context->submenu)  // функции *_get_view() есть у каждого компонента
    );

    // Popup
    // ...

    // Variable Item List
    // …

    // Dialog
    // ...

    // GUI
    furi_record_close(RECORD_GUI);
    context->gui = NULL;
    return context;
}

Освобождение памяти при этом происходит проще.

void nfc_crocodile_free(NfcCrocodile* context) {
    furi_assert(context);

    // Удаляем компонент из диспетчера
    view_dispatcher_remove_view(context->view_dispatcher, NfcCrocodileViewMenu);

    // Освобождаем все компоненты
    scene_manager_free(context->scene_manager);
    view_dispatcher_free(context->view_dispatcher);
    submenu_free(context->submenu);
    free(context);
}

Осталось модернизировать точку входа в приложение.

int32_t nfc_crocodile_main(void* p) {
    UNUSED(p);
    NfcCrocodile* context = nfc_crocodile_alloc();

    // Связываем диспетчера с отображением
    view_dispatcher_attach_to_gui(
        context->view_dispatcher, 
        gui, 
        ViewDispatcherTypeFullscreen
    );

    // Запускаем первую сцену, главное меню
    scene_manager_next_scene(context->scene_manager, NfcCrocodileSceneMainMenu);

    // Передаем управление диспетчеру
    view_dispatcher_run(context->view_dispatcher);

    // Если в главном меню нажать back, то диспетчер завершит свою работу,
    // выполнение этой функции закончится и приложение будет 
    // освобождать занятую память
    nfc_crocodile_free(context);

    return 0;
}

Теперь добавление новых сцен не должно вызывать проблем. В репозитории (ссылка в конце) можно посмотреть примеры использования остальных компонентов.

Есть кнопка Start Game, значит, нужно писать не только сцены, но и логику игры.

Чтение с накопителя


Оригинальная задумка от моих коллег из event-отдела заключалась в уникальном IT-словаре для игры. Так как в наших руках устройство с MicroSD-картой, то есть возможность предусмотреть функциональность выбора файла-словаря.

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

  • Учитываются только файлы, расположенные в каталоге данных приложения.
  • Внутренняя иерархия (вложенные каталоги) не просматривается.
  • Для отображения используем уже знакомый компонент Submenu.
  • В каталоге может быть не более N словарей, где N — это число, настраиваемое макросом. В текущей версии приложения N = 255.

Для работы с файлами нужен модуль storage в application.pam, а также объект типа Storage в структуре приложения. Добавляем:

typedef struct NfcCrocodile {
    // ...

    // Storage
    Storage* storage;
} NfcCrocodile;
NfcCrocodile* nfc_crocodile_alloc() {
    // …

    // Storage
    context->storage = furi_record_open(RECORD_STORAGE);
    storage_simply_mkdir(context->storage, STORAGE_APP_DATA_PATH_PREFIX);

   // ...
}
void nfc_crocodile_free(NfcCrocodile* context) {
    // ...

    // Storage
    furi_record_close(RECORD_STORAGE);

    // ...
}

Приложения могут иметь свой каталог по пути /app_data/{appid}. Но этот каталог не появится автоматически. Поэтому в инициализацию добавим вызов storage_simply_mkdir, который создаст каталог при первом запуске.

У приложения есть удобный псевдоним для «своего» каталога: /data. Разработчики Flipper Zero не гарантируют, что псевдоним останется неизменным, поэтому предлагают использовать набор макросов. Так как мне нужен только сам псевдоним, использую STORAGE_APP_DATA_PATH_PREFIX.

Создаем сцену nfc_crocodile_scene_dictionary и в событии on_event считываем список файлов. Взаимодействие с накопителем сильно похоже на системные вызовы в Linux.

// Глобальные переменные в сцене
#define MAX_LEN 255
char* files[MAX_LEN];
uint32_t files_len = 0;

void nfc_crocodile_scene_dictionary_on_enter(void* context) {
    // Здесь ничего нового
    NfcCrocodile* nfc_crocodile = context;
    submenu_reset(nfc_crocodile->submenu);
    submenu_set_header(nfc_crocodile->submenu, "Select dictionary");

    // Выделяем “файловый дескриптор”
    File* dir = storage_file_alloc(nfc_crocodile->storage);
    files_len = 0;

    // Открывается ли каталог?
    if(storage_dir_open(dir, STORAGE_APP_DATA_PATH_PREFIX)) {
        FileInfo file_info;
        char* buf = malloc(MAX_LEN * sizeof(char));
        uint16_t buf_len = 0;

        // Считываем записи в каталоге
        while(true) {
            // Считать не удалось, трактуем это как признак конца записей
            if(!storage_dir_read(dir, &file_info, buf, MAX_LEN)) {
                break;
            }

            // Каталоги нас не интересуют
            if(file_info_is_dir(&file_info)) {
                continue;
            }

            // Сохраняем имя файла для дальнейшего использования
            buf_len = strlen(buf) + 1;
            files[files_len] = malloc(buf_len * sizeof(char));
            memcpy(files[files_len], buf, buf_len);

            // Добавляем в submenu
            submenu_add_item(
                nfc_crocodile->submenu,
                files[files_len], // Имя файла
                files_len, // Текущий индекс файла
                nfc_crocodile_scene_dictionary_choice_callback,
                context);
            files_len++;
        }
        submenu_set_selected_item(
            nfc_crocodile->submenu,
            scene_manager_get_scene_state(
                nfc_crocodile->scene_manager, NfcCrocodileSceneDictionary));
        free(buf);
    } else {
        FURI_LOG_E(TAG, "Unable to open app data dir");
    }

    // Освобождаем файловый дескриптор
    storage_file_free(dir);
    view_dispatcher_switch_to_view(nfc_crocodile->view_dispatcher, NfcCrocodileViewMenu);
}

void nfc_crocodile_scene_dictionary_on_exit(void* context) {
    NfcCrocodile* nfc_crocodile = context;

    // При выходе освобождаем всю память из-под имен файлов
    for(uint32_t i = 0; i < files_len; i++) {
        free(files[i]);
    }

    submenu_reset(nfc_crocodile->submenu);
}

Возможность выбора файла реализована. Теперь нужно сделать такой формат файла, чтобы Flipper Zero мог быстро находить в нем случайное слово, которое будет загадано. Идея пришла быстро:

  1. Представляем словарь как набор слов для игры, разделенных некоторым символом.
  2. Открываем файл и смещаем указатель файлового дескриптора (seek) на случайное число байт.
  3. Считываем файл до ближайшего разделителя.
  4. Выбираем слово с первого найденного разделителя до второго.

Операция смещения указателя достаточно быстрая для MicroSD-карты, так что это хороший выбор. Сперва я выбрал в качестве разделителя запятую, но это ограничивало словарь. Тогда я решил использовать динамический разделитель: пусть первый символ в файле определяет разделитель. Для поддержки большинства языков удобно использовать кодировку UTF-8.

Количество октетов Значащих бит Шаблон
1 7 0xxxxxxx
2 11 110xxxxx 10xxxxxx
3 16 1110xxxx 10xxxxxx 10xxxxxx
4 21 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

Но, как известно, UTF-8 — это кодировка с переменной длинной, символ кодируется от 1 до 4 байт. Насколько это совместимо с процедурой перехода в случайное место файла? Как оказалось, поддержка этой совместимости требует минимальных усилий. Символы, которые кодируются одним байтом, имеют старший (восьмой) бит равный нулю.

NFC-крокодил: продвинутая разработка под Flipper Zero - 6

Для упрощения алгоритма установим, что разделитель должен кодироваться одним байтом, под это определение подходят все знаки препинания и английский алфавит. Однозначное кодирование октетов символов позволяет не бояться попадания в середину составного символа. Теперь реализуем это в коде.

bool nfc_crocodile_scene_dictionary_on_event(void* context, SceneManagerEvent event) {
    NfcCrocodile* nfc_crocodile = context;
    bool consumed = false;

    if(event.type == SceneManagerEventTypeCustom) {
        // Получаем имя файла по индексу
        const char* filename = files[event.event];

        // Собираем имя файла: каталог-псевдоним + имя файла
        FuriString* full_path_f = furi_string_alloc();
        path_concat(STORAGE_APP_DATA_PATH_PREFIX, filename, full_path_f);

        // Открываем файловый дескриптор
        const char* full_path = furi_string_get_cstr(full_path_f);
        File* f = storage_file_alloc(nfc_crocodile->storage);
        storage_file_open(f, full_path, FSAM_READ, FSOM_OPEN_EXISTING);

        // Получаем размер файла
        uint64_t file_size = storage_file_size(f);

        // Буфер для слов
        char buf[LOOKUP_LEN * 2];

        // Считываем разделитель
        char separator;
        storage_file_read(f, &separator, 1);

        // Здесь должна быть проверка на separator & 0b10000000

        // Находим последний разделитель, чтобы случайное событие
        // не могло привести к последнему слову
        storage_file_seek(f, file_size - LOOKUP_LEN, true);
        uint16_t read_count = storage_file_read(f, buf, LOOKUP_LEN);
        for(uint16_t i = 0; i < read_count; i++) {
            if(buf[read_count - i] == separator) {
                // Уменьшаем размер файла на длину последнего слова + разделитель
                file_size -= i + 1;
                break;
            }
        }

        // Выбираем место в файле
        uint32_t rnd = furi_hal_random_get() % file_size;

        // Считываем побольше за раз
        storage_file_seek(f, rnd, true);
        read_count = storage_file_read(f, buf, 2 * LOOKUP_LEN);
        buf[read_count + 1] = '';

        // Находим слово
        char* ptr = NULL;
        for(uint16_t i = 0; i < read_count; i++) {
            if(buf[i] != separator) continue;
            // Если разделитель еще не встречался, то это первый,
            // запоминаем его адрес
            if(!ptr) {
                ptr = buf + i + 1;
            }
            // Заменяем разделитель на нуль-терминатор
            buf[i] = '';
        }

        // Выбранное слово лежит между указателем ptr и ближайшим нуль-терминатором
        FURI_LOG_I(TAG, "Found word: %s", ptr);
        uint16_t word_len = strlen(ptr) + 1;

        // Копируем слово обработчику, который отправит его по NFC
        // Освобождаем память
        storage_file_close(f);
        storage_file_free(f);
        furi_string_free(full_path_f);

        // Сохраняем выбранный файл
        scene_manager_set_scene_state(
            nfc_crocodile->scene_manager, NfcCrocodileSceneDictionary, event.event);
        scene_manager_next_scene(nfc_crocodile->scene_manager, NfcCrocodileSceneTransfer);
    }
    return consumed;
}

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

Работа с NFC


В процессе подготовки статьи я начал именно с изучения работы NFC. На тот момент я ничего не знал и пытался сделать NFC-взаимодействие в основном потоке. У этого было два недостатка.

  1. Приложение почему-то «падало» с NULL pointer reference.
  2. Если приложение не падало, то я не мог удобно его контролировать. Или взаимодействие с приложением было ограничено временем или выход из приложения — это перезагрузка устройства.

Когда я добрался до реализации графического интерфейса и выделил работу с NFC в отдельный поток, то отладка стала более удобной, а приложение перестало падать.

Как я уже писал, NFC — это первое, что я пытался изучить в прошивке флиппера. В репозитории можно встретить приложение applications/main/nfc, которое отлично решает задачи копирования и эмуляции карт. Там даже есть метод mf_classic_emulator, который, судя по названию, как раз эмулирует метки Mifare Classic.

Здесь важный урок тем, кто слепо ищет удобные методы в прошивке Flipper Zero. В репозитории есть файл api_symbols.csv, который определяет, какие символы из прошивки доступны в пользовательских приложениях, а какие — нет. Если использовать не экспортированный символ, то приложение скомпилируется с предупреждением и даже установится, но флиппер откажется его запускать. Конечно методы для эмуляции карт не экспортированы.

В этом файле можно экспортировать нужные функции с помощью пересборки прошивки. Но пользовательская прошивка для игры — это тоже слишком. Поэтому поигрался, проиграл и не трогал больше этот файл.

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

Но вернемся к подготовке «места» для NFC-взаимодействия. Главный поток приложения занимается обработкой графического интерфейса, поэтому все общение выделим во второй поток. Такие служебные потоки чаще всего называют работниками (worker), поэтому и я сделаю так же. Воркер — это в каком-то смысле приложение внутри приложения, а значит, нужно сделать шаги из самого начала.

Создаем публичный интерфейс для воркера в файле nfc_crocodile_worker.h.

#pragma once
#include <lib/nfc/nfc_device.h>

// Неполный тип. Типизация важна, но мы не даем возможности
// управлять структурой снаружи воркера
typedef struct NfcCrocodileWorker NfcCrocodileWorker;

// Это настройка отображения, об этом позднее
typedef enum {
    NfcCrocodileStorageText = 0,
    NfcCrocodileStorageURL,
} NfcCrocodileStorageType;

// Состояния воркера. Для контроля и управления из основного потока
typedef enum {
    NfcCrocodileWorkerStateNone,
    NfcCrocodileWorkerStateReady,
    NfcCrocodileWorkerStateStop,
} NfcCrocodileWorkerState;

// Методы управления памятью
NfcCrocodileWorker* nfc_crocodile_worker_alloc();
void nfc_crocodile_worker_free(NfcCrocodileWorker* nfc_worker);

// Запуск и выключение воркера
void nfc_crocodile_worker_start(
    NfcCrocodileWorker* nfc_worker,
    NfcCrocodileWorkerState state,
    NfcCrocodileStorageType storage_type,
    char* str,
    void* callback,
    void* context);
void nfc_crocodile_worker_stop(NfcCrocodileWorker* nfc_worker);

Структура и все служебные методы описываются во «внутреннем» заголовочном файле nfc_crocodile_worker_i.h.

#pragma once
#include "nfc_crocodile_worker.h"
#include <furi.h>
#include <lib/nfc/protocols/nfc_util.h>
struct NfcCrocodileWorker {
    // Поток
    FuriThread* thread;

    // Функция обратного вызова и аргумент для нее
    bool (*callback)(void*);
    void* context;

    // Состояние воркера
    NfcCrocodileWorkerState state;
    bool connection;

    // Данные метки, это разберем позднее
    uint8_t uid_data[7];
    uint8_t atqa_data[2];
    uint8_t sak;
    uint8_t card[0x87 * 4];
    char* card_content;
    NfcCrocodileStorageType storage_type;
};

// Несколько методов пропущено
// Основной метод воркера
int32_t nfc_crocodile_worker_task(void* context);

Теперь реализуем описанные методы в nfc_crocodile_worker.c. Начнем с создания и завершения потока.

NfcCrocodileWorker* nfc_crocodile_worker_alloc() {
    NfcCrocodileWorker* nfc_crocodile_worker =
        (NfcCrocodileWorker*)malloc(sizeof(NfcCrocodileWorker));

    // ...

    nfc_crocodile_worker->thread = furi_thread_alloc_ex(
        "NfcCrocodileWorker", // Имя потока
        8192, // размер стека
        nfc_crocodile_worker_task, // точка входа 
        nfc_crocodile_worker // Аргумент для точки входа
    );

    return nfc_crocodile_worker;
}

void nfc_crocodile_worker_free(NfcCrocodileWorker* nfc_crocodile_worker) {
    furi_thread_free(nfc_crocodile_worker->thread);
    free(nfc_crocodile_worker);
}

void nfc_crocodile_worker_start(
    NfcCrocodileWorker* nfc_crocodile_worker,
    NfcCrocodileWorkerState state,
    NfcCrocodileStorageType storage_type,
    char* str,
    void* callback,
    void* context) {
    // Собираем информацию от запускающего
    nfc_crocodile_worker->storage_type = storage_type;
    nfc_crocodile_worker->card_content = str;
    nfc_crocodile_worker->callback = callback;
    nfc_crocodile_worker->context = context;
    nfc_crocodile_worker_change_state(nfc_crocodile_worker, state);

    // Запускаем поток
    furi_thread_start(nfc_crocodile_worker->thread);
}

void nfc_crocodile_worker_stop(NfcCrocodileWorker* nfc_crocodile_worker) {
    // Проверяем, что поток запущен
    if(furi_thread_get_state(nfc_crocodile_worker->thread) != FuriThreadStateStopped) {
        // Меняем состояние, чтобы внутренний цикл воркера завершился
        nfc_crocodile_worker_change_state(nfc_crocodile_worker, NfcCrocodileWorkerStateStop);

        // Ожидаем завершения потока
        furi_thread_join(nfc_crocodile_worker->thread);
    }
}

Теперь реализуем точку входа для потока.

int32_t nfc_crocodile_worker_task(void* context) {
    NfcCrocodileWorker* nfc_crocodile_worker = context;

    // Пробуждаем модуль NFC
    furi_hal_nfc_exit_sleep();

    // Основной цикл
    while(nfc_crocodile_worker->state == NfcCrocodileWorkerStateReady) {
        furi_hal_nfc_emulate_nfca(
            nfc_crocodile_worker->uid_data, // Уникальный идентификатор метки
            7, // Размер (в байтах) идентификатора
            nfc_crocodile_worker->atqa_data, // ATQA
            nfc_crocodile_worker->sak,  // SAK
            nfc_crocodile_worker_callback, // Функция обработки взаимодействия
            nfc_crocodile_worker, 
            1000 // Таймаут ожидания связи
        );
    }

    // Отправляем в сон
    furi_hal_nfc_sleep();
    return 0;
}

Для запуска эмуляции нужны параметры карты, такие как уникальный идентификатор, ATQA и SAK. Все значения можно честно позаимствовать у любой метки, которая найдется дома или воспользоваться приложением NFC во флиппере. Там есть возможность сгенерировать пустую метку нужного типа.

Значения ATQA и SAK задают тип метки и от этих значений зависят действия и команды читающего устройства. Ранее я уже знакомился с форматом данных MiFare Classic, и он мне показался весьма сложным: разбиение данных на секторы, у каждого сектора по два ключа, криптография, безопасность. Оказалось что у NXP есть метки NTAG21x, которые имеют простую страничную структуру. Отправляем в поисковик запрос NTAG215 pdf и сразу получаем даташит.

В документации представлены три вида меток: NTAG213, NTAG215 и NTAG216, отличающиеся объемом доступной памяти. Я выбрал NTAG215 на 540 байт памяти. Из этого следуют такие значения:

  • UID: 04 59 44 C9 1A E0 8C
  • ATQA: 0x4400
  • SAK: 0x00

Примечание: первый байт идентификатора 0x04, так как он указывает на производителя. Остальные байты UID можно генерировать в процессе.

Функция furi_hal_nfc_emulate_nfca абстрагирует нас от всех физических проблем и решений, которые притаились в стандарте ISO 14443. В функцию обратного вызова приходят два буфера: из одного можно прочесть данные, а в другой — записать. Дополнительно доступны флаги передачи, но если оставить их по умолчанию, то флиппер будет считать контрольные суммы за нас.

bool nfc_crocodile_worker_callback(
    uint8_t* buff_rx,  // буфер с принятыми байтами
    uint16_t buff_rx_len, // размер 
    uint8_t* buff_tx, // буфер с байтами, которые надо отправить
    uint16_t* buff_tx_len, // размер
    uint32_t* flags, // флаги передачи
    void* context // контекст
) {
    NfcCrocodileWorker* nfc_crocodile_worker = context;
    return 1;
}

Казалось бы, реализуй все по спецификации и будет радость. Но я потратил почти день на то, чтобы разобрать странную ситуацию. Рассмотрим команду READ, которая, согласно спецификации, состоит из двух байт. Первый байт фиксированный, 0x30, а второй определяет номер страницы, с которой нужно начать чтение. Ответ — содержимое указанной страницы и трех следующих. Вот минимальный отладочный вывод перед отправкой:

buff_rx_len = 16
buff_rx = 
         0x30 0x00 0x03 0x00 
         0x00 0xA0 0xDE 0x00 
         0x00 0x74 0x00 0x00 
         0x00 0x00 0x08 0x00
buff_tx_len = 16
buff_tx = 
         0x04 0x59 0x44 0x91
         0xC9 0x1A 0xE0 0x8C
         0xBF 0x48 0x00 0x00
         0xE1 0x10 0x3E 0x00

При начале взаимодействия телефон отправляет первую команду READ 0, а флиппер отвечает подготовленными данными. Телефон не подает признаков обнаружения метки, а в логах флиппера видно, что телефон до десяти раз в секунду отправляет команду READ 0. Если разорвать физическое соединение, то логи остановятся. Вот загадка: что пошло не так и как это исправить? Гарантируется, что сформированные 16 байт полностью соответствуют спецификации.

Решение

buff_rx_len = 16, хотя спецификация заявляет, что команда READ занимает ровно два байта. Я списал это на особенности реализации, а лишние 14 байт не использовал. Позднее это несоответствие приелось, и я даже не обращал на него внимание. А зря. 16 — это размер в битах, а значит, buff_tx_len должен быть не 16, а 16*8, то есть 128. Телефон получал лишь одну восьмую от ожидаемой информации и перезапрашивал данные.

Такая неочевидность находит обоснование позже. Команда ACK / NAK занимает 4 бита. Узнал я это позднее, когда реализовывал «заглушки» для неподдерживаемых команд.

Для эмуляции NTAG215 я реализовал следующие команды:

  • READ — чтение 4 страниц памяти по адресу X.
  • FAST_READ — чтение страниц на промежутке [X; Y].
  • GET_VERSION — информация о карте.
  • PWD_AUTH — аутентификация. В моем случае принимает любой пароль.

Остальные операции не реализованы, и на них возвращается NAK. В метках первые несколько страниц занимают служебные данные, которые описывают метку. В описании также есть контрольные суммы, которые придется вычислять самому.

NFC-крокодил: продвинутая разработка под Flipper Zero - 7

Так как половина из операций — это чтение со смещением, то хорошим решением кажется сделать представление данных метки в памяти и сводить операцию чтения к копированию данных из представления в buff_rx. Заполняем первые 4 и последние 5 страниц служебными данными, а промежуточные страницы — нулями.

После внесенных изменений специализированные приложения, например, NXP Tag Info будут корректно считывать метку и показывать ее содержимое. Но конечная цель — метки, которые считываются телефоном без дополнительных приложений. В этой задаче поможет NDEF-сообщения.

NDEF-сообщения


NDEF — это стандартизированный формат обмена сообщениями. Спецификации можно почитать на сайте организации NFC Forum, но это платное удовольствие. Тем не менее, не обязательно поднимать «веселого Роджера», есть несколько возможностей ознакомиться с форматом NDEF.

Во-первых, на сайте Nordic Semiconductor есть описание формата в контексте их SDK. Этого достаточно, чтобы поверхностно ознакомиться с основами формата. Во-вторых, приложение NFC Tools на Android позволяет создавать собственные сообщения и записывать их на метку. А если метки нет, то приложение NFC, встроенное в ОС флиппера, поддерживает операции записи.

NFC-крокодил: продвинутая разработка под Flipper Zero - 8

Сообщение состоит из двух частей: заголовка и тела. Заголовок включает в себя набор флагов, длину типа в байтах, строковое представление типа и длину типа. Опять же, на словах все просто, но если записать NDEF-сообщение на метку по этому описанию, то чуда не произойдет. Чуйка подсказывала, что NDEF-сообщения хранятся в каком-то контейнере, но описание этого контейнера я найти так и не смог. Тогда я стал записывать разные данные на метку и гадал по байтам. Прозрение пришло в спецификации NTAG215 в разделе «Memory content at delivery». Согласно спецификации, на четвертой странице памяти лежат следующие данные.

0x03 0x00 0xFE 0x00

После записи NDEF-сообщения на метку я заметил следующее:

0x03 0x0B <12 байт пропущено> 0xFE

Похоже, что «контейнер» для сообщений — это 0x03 в качестве заголовка, потом один байт размер, потом сообщения и в конце байт 0xFE, который не входит в размер контейнера. NTAG215 и NTAG216 позволяют хранить больше чем 255 байт информации. Как тогда будет выглядеть контейнер?

0x33 0xFF 0x02 0x63 <611 байт пропущено> 0xFE

Так как 611 = 0x0263, а «хранилище» одинаково поддерживает оба формата, то остановимся на «расширенном» формате, где размер полезной нагрузки записывается двумя байтами. Спецификация NDEF позволяет хранить великое множество форматов, в том числе с возможностью явно задать mime-type. Без специальных приложений телефоны поддерживают только форматы типа NFC Forum well-known type. Тип «T», то есть текст, идеально подходит.

NFC-крокодил: продвинутая разработка под Flipper Zero - 9

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

Проблема пришла позднее. Оказалось, что iOS не воспринимает сообщения с типом T, только ссылки, то есть тип U. К счастью, решение пришло быстро: можно использовать ссылку на практически любой поисковик с query-параметром q. Например:

duckduckgo.com/?q=factory

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

Вот и закончились основные препятствия, осталось «допилить» приложение и можно наслаждаться игрой.

Демонстрация игры.

Исходный код приложения доступен в репозитории.

Если вас интересует такое «ненормальное программирование» и балуетесь созданием нетипичных игр, посмотрите другие мои разработки:

Дополнительное чтение

Автор: Владимир

Источник

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


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