Merge pull request #31 from 7h30th3r0n3/feature/nfc-scan

feat: add NFC tag scanning to auto-fill ESL barcode
This commit is contained in:
I12BP8
2026-04-26 13:35:36 +02:00
committed by GitHub
11 changed files with 307 additions and 5 deletions
+4 -1
View File
@@ -1,4 +1,4 @@
# TagTinker V2.0
# TagTinker V2.1
<p align="center">
<strong>Infrared ESL Research Toolkit for Flipper Zero</strong><br>
@@ -29,6 +29,7 @@ This tool is built for IoT security curiosity, learning about obscure protocols,
- **TagTinker Flipper App:** High-performance, zero-allocation RLE streaming IR engine.
- **TagTinker Android Companion:** Edit and dither images directly on your phone and sync them instantly to the Flipper Zero over BLE.
- **NFC Tag Scan:** Instantly identify ESL targets by scanning their NFC tag — no manual barcode entry needed.
- Display text, custom images, and test-patterns.
- Support for monochrome and accent-color (red/yellow) graphics tags.
@@ -61,6 +62,8 @@ To understand the underlying protocol, signal structure, and history, please rea
- **Furrteks ESL research:** [https://www.furrtek.org/?a=esl](https://www.furrtek.org/?a=esl)
- **PrecIR reference implementation:** [https://github.com/furrtek/PrecIR](https://github.com/furrtek/PrecIR)
NFC tag decoding contributed by **7h30th3r0n3**.
## Disclaimer
> [!CAUTION]
+4 -2
View File
@@ -3,13 +3,13 @@ App(
name="TagTinker",
apptype=FlipperAppType.EXTERNAL,
entry_point="tagtinker_app_main",
requires=["gui", "notification", "dialogs", "storage", "bt"],
requires=["gui", "notification", "dialogs", "storage", "bt", "nfc"],
stack_size=12 * 1024,
fap_icon="tagtinker_10px.png",
fap_category="Infrared",
fap_author="i12bp8",
fap_description="Educational ESL study tool for owned hardware",
fap_version="2.0",
fap_version="2.1",
sources=[
"tagtinker_app.c",
"ir/tagtinker_ir.c",
@@ -32,5 +32,7 @@ App(
"scenes/tagtinker_scene_text_box.c",
"scenes/tagtinker_scene_warning.c",
"views/numlock_input.c",
"nfc/tagtinker_nfc.c",
"scenes/tagtinker_scene_nfc_scan.c",
],
)
+108
View File
@@ -0,0 +1,108 @@
/*
* TagTinker — ESL NFC tag decoder (implementation)
*
* ESL tags contain an NDEF URI whose last 10 characters
* encode the ESL ID using a custom base64 alphabet.
* This module decodes them into the 17-char barcode format
* expected by tagtinker_barcode_to_plid().
*/
#include "tagtinker_nfc.h"
#include <string.h>
/* Direct ASCII-to-index lookup table, -1 = not in alphabet */
static const int8_t CHAR_LUT[128] = {
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,35,-1,-1,
28,24,30,38,58, 5,23, 6, 3,40,-1,-1,-1,-1,-1,-1,
-1,20,15,54,16,44,46,63, 4,48,34,19,37, 0,26, 1,
8,41,31, 2,45,55,60,12,11,57,33,-1,-1,-1,-1,50,
-1,13,39, 9,43,18,29,52,59, 7,61,62,14,25,32,56,
42,47,53,22,36,49,10,21,17,27,51,-1,-1,-1,-1,-1,
};
static int alphabet_index(char c) {
uint8_t idx = (uint8_t)c;
if(idx >= 128) return -1;
return CHAR_LUT[idx];
}
static uint32_t decode_b64(const char* s, int len) {
uint32_t r = 0;
for(int i = 0; i < len; i++) {
int idx = alphabet_index(s[(len - 1) - i]);
if(idx < 0) return 0;
r = (r * 64) + (uint32_t)idx;
}
return r;
}
static bool decode_tag(const char* tag, char barcode[18]) {
if(strlen(tag) != 10) return false;
for(int i = 0; i < 10; i++) {
if(alphabet_index(tag[i]) < 0) return false;
}
uint32_t val1 = decode_b64(tag + 5, 5);
uint32_t val2 = decode_b64(tag, 5);
char raw[20];
snprintf(raw, sizeof(raw), "%09lu%09lu", (unsigned long)val1, (unsigned long)val2);
int lc = (raw[0] - '0') * 10 + (raw[1] - '0');
if(lc > 25) return false;
char letter = (char)(lc + 65);
barcode[0] = letter;
memcpy(barcode + 1, raw + 2, 16);
barcode[17] = '\0';
if(barcode[1] != '4') return false;
int cs = 0;
for(int i = 0; i < 16; i++) {
char c = barcode[i];
cs += (c >= 'a' && c <= 'z') ? (c - 32) : c;
}
return (cs % 10) == (barcode[16] - '0');
}
static bool extract_from_pages(const MfUltralightData* mfu, char barcode[18]) {
if(mfu->pages_read < 11) return false;
const uint8_t* p3 = mfu->page[3].data;
if(p3[0] != 0xE1) return false;
const uint8_t* p4 = mfu->page[4].data;
if(p4[0] != 0x03) return false;
uint8_t ndef_len = p4[1];
if(ndef_len < 5) return false;
uint8_t flat[28];
for(int i = 0; i < 7; i++) {
memcpy(flat + i * 4, mfu->page[4 + i].data, 4);
}
int payload_end = 6 + (ndef_len - 4);
if(payload_end > 28) payload_end = 28;
char url_body[40] = {0};
int j = 0;
for(int i = 6; i < payload_end && j < 39; i++) {
if(flat[i] == 0xFE) break;
url_body[j++] = (char)flat[i];
}
const char* last_slash = strrchr(url_body, '/');
if(!last_slash) return false;
return decode_tag(last_slash + 1, barcode);
}
bool tagtinker_nfc_decode_barcode(const MfUltralightData* mfu_data, char barcode[18]) {
if(!mfu_data) return false;
barcode[0] = '\0';
return extract_from_pages(mfu_data, barcode);
}
+14
View File
@@ -0,0 +1,14 @@
/*
* TagTinker — ESL NFC tag decoder
*
* Decodes NDEF URI from ESL Mifare Ultralight tags
* into the 17-character barcode format used by TagTinker.
*/
#pragma once
#include <stdint.h>
#include <stdbool.h>
#include <nfc/protocols/mf_ultralight/mf_ultralight.h>
bool tagtinker_nfc_decode_barcode(const MfUltralightData* mfu_data, char barcode[18]);
+3
View File
@@ -21,6 +21,7 @@ void(*const tagtinker_scene_on_enter_handlers[])(void*) = {
tagtinker_scene_transmit_on_enter,
tagtinker_scene_about_on_enter,
tagtinker_scene_text_box_on_enter,
tagtinker_scene_nfc_scan_on_enter,
};
bool(*const tagtinker_scene_on_event_handlers[])(void*, SceneManagerEvent) = {
@@ -40,6 +41,7 @@ bool(*const tagtinker_scene_on_event_handlers[])(void*, SceneManagerEvent) = {
tagtinker_scene_transmit_on_event,
tagtinker_scene_about_on_event,
tagtinker_scene_text_box_on_event,
tagtinker_scene_nfc_scan_on_event,
};
void(*const tagtinker_scene_on_exit_handlers[])(void*) = {
@@ -59,6 +61,7 @@ void(*const tagtinker_scene_on_exit_handlers[])(void*) = {
tagtinker_scene_transmit_on_exit,
tagtinker_scene_about_on_exit,
tagtinker_scene_text_box_on_exit,
tagtinker_scene_nfc_scan_on_exit,
};
const SceneManagerHandlers tagtinker_scene_handlers = {
+5
View File
@@ -23,6 +23,7 @@ typedef enum {
TagTinkerSceneTransmit,
TagTinkerSceneAbout,
TagTinkerSceneTextBox,
TagTinkerSceneNfcScan,
TagTinkerSceneCount,
} TagTinkerScene;
@@ -89,3 +90,7 @@ void tagtinker_scene_about_on_exit(void* ctx);
void tagtinker_scene_text_box_on_enter(void* ctx);
bool tagtinker_scene_text_box_on_event(void* ctx, SceneManagerEvent event);
void tagtinker_scene_text_box_on_exit(void* ctx);
void tagtinker_scene_nfc_scan_on_enter(void* ctx);
bool tagtinker_scene_nfc_scan_on_event(void* ctx, SceneManagerEvent event);
void tagtinker_scene_nfc_scan_on_exit(void* ctx);
+1
View File
@@ -524,6 +524,7 @@ static void about_draw_cb(Canvas* canvas, void* _model) {
canvas_draw_str_aligned(canvas, 64, 10, AlignCenter, AlignTop, TAGTINKER_DISPLAY_NAME " v" TAGTINKER_VERSION);
canvas_draw_str_aligned(canvas, 64, 24, AlignCenter, AlignTop, "Ported by I12BP8");
canvas_draw_str_aligned(canvas, 64, 34, AlignCenter, AlignTop, "Research by furrtek");
canvas_draw_str_aligned(canvas, 64, 44, AlignCenter, AlignTop, "NFC by 7h30th3r0n3");
}
}
+134
View File
@@ -0,0 +1,134 @@
/*
* NFC Scan scene — scan an ESL NFC tag to fill barcode
*/
#include "../tagtinker_app.h"
enum {
NfcScanEventSuccess = 1,
NfcScanEventNotEsl = 2,
};
static int32_t nfc_scan_thread(void* ctx) {
TagTinkerApp* app = ctx;
while(app->nfc_scanning) {
MfUltralightData* mfu_data = mf_ultralight_alloc();
MfUltralightError err =
mf_ultralight_poller_sync_read_card(app->nfc, mfu_data, NULL);
if(err == MfUltralightErrorNone) {
char barcode[18];
bool decoded = tagtinker_nfc_decode_barcode(mfu_data, barcode);
mf_ultralight_free(mfu_data);
if(!app->nfc_scanning) return 0;
if(decoded) {
memcpy(app->barcode, barcode, TAGTINKER_BC_LEN);
app->barcode[TAGTINKER_BC_LEN] = '\0';
view_dispatcher_send_custom_event(
app->view_dispatcher, NfcScanEventSuccess);
} else {
view_dispatcher_send_custom_event(
app->view_dispatcher, NfcScanEventNotEsl);
}
return 0;
}
mf_ultralight_free(mfu_data);
if(!app->nfc_scanning) break;
furi_delay_ms(100);
}
return 0;
}
void tagtinker_scene_nfc_scan_on_enter(void* ctx) {
TagTinkerApp* app = ctx;
popup_reset(app->popup);
popup_set_header(app->popup, "Scan NFC Tag", 64, 10, AlignCenter, AlignTop);
popup_set_text(
app->popup, "Hold ESL tag\nto Flipper back", 64, 32, AlignCenter, AlignCenter);
view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewPopup);
notification_message(app->notifications, &sequence_blink_start_cyan);
app->nfc = nfc_alloc();
app->nfc_scanning = true;
furi_thread_set_callback(app->nfc_thread, nfc_scan_thread);
furi_thread_set_context(app->nfc_thread, app);
furi_thread_start(app->nfc_thread);
}
bool tagtinker_scene_nfc_scan_on_event(void* ctx, SceneManagerEvent event) {
TagTinkerApp* app = ctx;
if(event.type != SceneManagerEventTypeCustom) return false;
if(event.event == NfcScanEventSuccess) {
int8_t idx = tagtinker_ensure_target(app, app->barcode);
if(idx < 0) {
popup_reset(app->popup);
popup_set_header(
app->popup, "Decode Error", 64, 20, AlignCenter, AlignCenter);
popup_set_text(
app->popup, "Tag read but\nbarcode invalid", 64, 36, AlignCenter, AlignCenter);
popup_set_timeout(app->popup, 2000);
popup_enable_timeout(app->popup);
popup_set_callback(app->popup, NULL);
view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewPopup);
return true;
}
tagtinker_select_target(app, (uint8_t)idx);
FURI_LOG_I(
TAGTINKER_TAG,
"NFC: %s -> PLID %02X%02X%02X%02X",
app->barcode,
app->plid[3],
app->plid[2],
app->plid[1],
app->plid[0]);
notification_message(app->notifications, &sequence_success);
scene_manager_next_scene(app->scene_manager, TagTinkerSceneTargetActions);
return true;
}
if(event.event == NfcScanEventNotEsl) {
popup_reset(app->popup);
popup_set_header(
app->popup, "Not an ESL tag", 64, 20, AlignCenter, AlignCenter);
popup_set_text(
app->popup, "Tag detected but\nno valid ESL data", 64, 36, AlignCenter, AlignCenter);
popup_set_timeout(app->popup, 2000);
popup_enable_timeout(app->popup);
popup_set_callback(app->popup, NULL);
view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewPopup);
return true;
}
return false;
}
void tagtinker_scene_nfc_scan_on_exit(void* ctx) {
TagTinkerApp* app = ctx;
app->nfc_scanning = false;
if(app->nfc) {
furi_thread_join(app->nfc_thread);
nfc_free(app->nfc);
app->nfc = NULL;
}
notification_message(app->notifications, &sequence_blink_stop);
popup_reset(app->popup);
}
+8 -1
View File
@@ -5,6 +5,7 @@
#include "../tagtinker_app.h"
enum {
TargetMenuScanNfc = 99,
TargetMenuAddNew = 100,
};
@@ -20,7 +21,8 @@ void tagtinker_scene_target_menu_on_enter(void* ctx) {
submenu_set_header(app->submenu, "Targeted Payloads");
/* Add new target */
submenu_add_item(app->submenu, "+ New Target", TargetMenuAddNew, target_menu_cb, app);
submenu_add_item(app->submenu, "+ Scan NFC", TargetMenuScanNfc, target_menu_cb, app);
submenu_add_item(app->submenu, "+ Type Barcode", TargetMenuAddNew, target_menu_cb, app);
/* List saved targets */
for(uint8_t i = 0; i < app->target_count; i++) {
@@ -40,6 +42,11 @@ bool tagtinker_scene_target_menu_on_event(void* ctx, SceneManagerEvent event) {
TagTinkerApp* app = ctx;
if(event.type != SceneManagerEventTypeCustom) return false;
if(event.event == TargetMenuScanNfc) {
scene_manager_next_scene(app->scene_manager, TagTinkerSceneNfcScan);
return true;
}
if(event.event == TargetMenuAddNew) {
/* Go to barcode input, then come back */
scene_manager_set_scene_state(
+15
View File
@@ -740,6 +740,14 @@ static void app_free(TagTinkerApp* app) {
furi_thread_free(app->tx_thread);
/* NFC cleanup */
if(app->nfc) {
app->nfc_scanning = false;
furi_thread_join(app->nfc_thread);
nfc_free(app->nfc);
}
furi_thread_free(app->nfc_thread);
furi_record_close(RECORD_GUI);
furi_record_close(RECORD_NOTIFICATION);
furi_record_close(RECORD_DIALOGS);
@@ -883,6 +891,13 @@ static TagTinkerApp* app_alloc(void) {
furi_thread_set_priority(app->tx_thread, FuriThreadPriorityHighest);
furi_thread_set_context(app->tx_thread, app);
/* NFC scan thread */
app->nfc_thread = furi_thread_alloc();
furi_thread_set_name(app->nfc_thread, "TagTinkerNfc");
furi_thread_set_stack_size(app->nfc_thread, 2048);
app->nfc = NULL;
app->nfc_scanning = false;
return app;
}
+11 -1
View File
@@ -21,15 +21,20 @@
#include <bt/bt_service/bt.h>
#include <targets/f7/ble_glue/profiles/serial_profile.h>
#include <nfc/nfc.h>
#include <nfc/protocols/mf_ultralight/mf_ultralight.h>
#include <nfc/protocols/mf_ultralight/mf_ultralight_poller_sync.h>
#include "views/numlock_input.h"
#include "scenes/tagtinker_scene.h"
#include "protocol/tagtinker_proto.h"
#include "ir/tagtinker_ir.h"
#include "nfc/tagtinker_nfc.h"
#define TAGTINKER_TAG "TagTinker"
#define TAGTINKER_DISPLAY_NAME "TagTinker"
#define TAGTINKER_VERSION "2.0"
#define TAGTINKER_VERSION "2.1"
#define TAGTINKER_BC_LEN 17
#define TAGTINKER_HEX_LEN 64
#define TAGTINKER_MAX_TARGETS 16
@@ -127,6 +132,11 @@ struct TagTinkerApp {
bool tx_active;
FuriThread* tx_thread;
/* NFC scan state */
Nfc* nfc;
FuriThread* nfc_thread;
volatile bool nfc_scanning;
/* Broadcast settings */
uint8_t broadcast_type;
uint8_t page;