Compare commits

...
8 Commits
Author SHA1 Message Date
d4rks1d33 2a5c442503 IR as main app again but maybe remove later and make it external
Build Dev Firmware / build (push) Waiting to run
2026-07-18 18:51:41 -03:00
David 90e1c2b850 Fix custom emulate placeholder metadata
Build Dev Firmware / build (push) Waiting to run
2026-07-18 15:46:00 +02:00
David b6d8d67c6e Fix custom emulate decoded metadata display
Build Dev Firmware / build (push) Waiting to run
2026-07-18 15:22:59 +02:00
David ac8909904e Add custom emulate button metadata fallback
Build Dev Firmware / build (push) Canceled after 0s
2026-07-17 15:55:02 +02:00
David d4d8b0e646 Initialize custom emulate button metadata 2026-07-17 15:49:57 +02:00
David 36483ec07d Align custom emulate button labels
Build Dev Firmware / build (push) Canceled after 0s
2026-07-17 14:12:38 +02:00
David 807e74b7d6 Use custom emulate UI when enabled
Build Dev Firmware / build (push) Canceled after 0s
2026-07-17 13:47:28 +02:00
David f4fe2d48c1 Improve SubGhz proto filter handling
Build Dev Firmware / build (push) Has been cancelled
2026-07-15 12:03:08 +02:00
106 changed files with 16044 additions and 659 deletions
+1
View File
@@ -9,6 +9,7 @@ App(
"subghz",
"subghz_remote",
"subghz_bruteforcer",
"infrared",
"archive",
"main_apps_on_start",
],
@@ -0,0 +1,113 @@
#include "subghz_button_labels.h"
#include <furi.h>
#include <lib/subghz/blocks/custom_btn.h>
#include <string.h>
static const char* const button_default_labels[SUBGHZ_BUTTON_LABEL_COUNT] = {
"Original",
"Up",
"Down",
"Left",
"Right",
"Button 5",
"Button 6",
"Button 7",
};
typedef struct {
const char* protocol;
uint8_t max_custom_btn;
const char* labels[SUBGHZ_BUTTON_LABEL_COUNT];
} SubGhzProtocolButtonLabels;
static const SubGhzProtocolButtonLabels protocol_button_labels[] = {
{"VAG GROUP", 4, {"Original", "Lock", "Unlock", "Trunk", "Panic"}},
{"Porsche AG", 4, {"Original", "Lock", "Unlock", "Trunk", "Open"}},
{"FORD V0", 3, {"Original", "Lock", "Unlock", "Trunk"}},
{"Ford V2", 4, {"Unlock", "Lock", "Trunk", "Panic", "Remote Start"}},
{"PSA GROUP", 4, {"Original", "Lock", "Unlock", "Trunk", "Trunk"}},
{"PSA OLD", 4, {"Original", "Lock", "Unlock", "Trunk", "Trunk"}},
{"KIA/HYU V0", 4, {"Original", "Lock", "Unlock", "Trunk", "Horn"}},
{"KIA/HYU V1", 4, {"Original", "Lock", "Unlock", "Trunk", "Panic"}},
{"KIA/HYU V2", 4, {"Original", "Lock", "Unlock", "Trunk", "Panic"}},
{"KIA/HYU V3", 5, {"Original", "Lock", "Unlock", "Trunk", "Panic", "Horn"}},
{"KIA/HYU V4", 5, {"Original", "Lock", "Unlock", "Trunk", "Panic", "Horn"}},
{"KIA/HYU V3/V4", 5, {"Original", "Lock", "Unlock", "Trunk", "Panic", "Horn"}},
{"KIA/HYU V5", 4, {"Original", "Unlock", "Lock", "Trunk", "Horn"}},
{"KIA/HYU V6", 4, {"Original", "Lock", "Unlock", "Trunk", "Panic"}},
{"SUBARU", 5, {"Original", "Lock", "Unlock", "Trunk", "Panic", "Extra"}},
{"SUZUKI", 4, {"Original", "Lock", "Unlock", "Trunk", "Panic"}},
{"Star Line", 4, {"Original", "Lock", "Unlock", "Trunk", "Start"}},
{"Scher-Khan", 4, {"Original", "Lock", "Unlock", "Trunk", "Start"}},
{"Sheriff CFM", 4, {"Original", "Lock", "Unlock", "Trunk", "Panic"}},
{"Nice FloR-S", 4, {"Original", "Btn 1", "Btn 2", "Btn 3", "Btn 4"}},
{"CAME Atomo", 3, {"Original", "Btn 1", "Btn 2", "Btn 3"}},
{"Alutech AT-4N", 4, {"Original", "Btn 1", "Btn 2", "Btn 3", "Btn 4"}},
{"KeeLoq", 4, {"Original", "Btn 1", "Btn 2", "Btn 3", "Btn 4"}},
{"Phoenix_V2", 4, {"Original", "Btn 1", "Btn 2", "Btn 3", "Btn 4"}},
{"Beninca ARC", 2, {"Original", "Btn 1", "Btn 2"}},
{"GangQi", 3, {"Original", "Btn 1", "Btn 2", "Btn 3"}},
{"Hay21", 2, {"Original", "Btn 1", "Btn 2"}},
{"Hollarm", 3, {"Original", "Btn 1", "Btn 2", "Btn 3"}},
{"Jarolift", 3, {"Original", "Btn 1", "Btn 2", "Btn 3"}},
{"KingGates Stylo4k", 3, {"Original", "Btn 1", "Btn 2", "Btn 3"}},
{"Princeton", 4, {"Original", "Btn 1", "Btn 2", "Btn 3", "Btn 4"}},
{"Roger", 3, {"Original", "Btn 1", "Btn 2", "Btn 3"}},
{"Security+ 2.0", 4, {"Original", "Btn 1", "Btn 2", "Btn 3", "Btn 4"}},
{"Somfy Telis", 3, {"Original", "Btn 1", "Btn 2", "Btn 3"}},
{"Faac SLH", 1, {"Original", "Btn 1"}},
};
void subghz_button_labels_reset(const char* labels[SUBGHZ_BUTTON_LABEL_COUNT]) {
for(uint8_t i = 0; i < SUBGHZ_BUTTON_LABEL_COUNT; i++) {
labels[i] = button_default_labels[i];
}
}
void subghz_button_labels_apply_protocol(
const char* protocol,
const char* labels[SUBGHZ_BUTTON_LABEL_COUNT]) {
if(!protocol) return;
for(uint8_t i = 0; i < COUNT_OF(protocol_button_labels); i++) {
if(strcmp(protocol, protocol_button_labels[i].protocol) == 0) {
for(uint8_t btn = 0; btn < SUBGHZ_BUTTON_LABEL_COUNT; btn++) {
if(protocol_button_labels[i].labels[btn]) {
labels[btn] = protocol_button_labels[i].labels[btn];
}
}
break;
}
}
}
const char* subghz_button_labels_get(
const char* const labels[SUBGHZ_BUTTON_LABEL_COUNT],
uint8_t custom_btn_id,
uint8_t original_custom_btn) {
if(custom_btn_id == SUBGHZ_CUSTOM_BTN_OK) {
if((original_custom_btn != SUBGHZ_CUSTOM_BTN_OK) &&
(original_custom_btn < SUBGHZ_BUTTON_LABEL_COUNT)) {
return labels[original_custom_btn];
}
}
if(custom_btn_id < SUBGHZ_BUTTON_LABEL_COUNT) {
return labels[custom_btn_id];
}
return "Button";
}
uint8_t subghz_button_labels_get_max_custom_btn(const char* protocol) {
if(!protocol) return 0;
for(uint8_t i = 0; i < COUNT_OF(protocol_button_labels); i++) {
if(strcmp(protocol, protocol_button_labels[i].protocol) == 0) {
return protocol_button_labels[i].max_custom_btn;
}
}
return 0;
}
@@ -0,0 +1,26 @@
#pragma once
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
#define SUBGHZ_BUTTON_LABEL_COUNT 8
void subghz_button_labels_reset(const char* labels[SUBGHZ_BUTTON_LABEL_COUNT]);
void subghz_button_labels_apply_protocol(
const char* protocol,
const char* labels[SUBGHZ_BUTTON_LABEL_COUNT]);
const char* subghz_button_labels_get(
const char* const labels[SUBGHZ_BUTTON_LABEL_COUNT],
uint8_t custom_btn_id,
uint8_t original_custom_btn);
uint8_t subghz_button_labels_get_max_custom_btn(const char* protocol);
#ifdef __cplusplus
}
#endif
@@ -9,11 +9,13 @@
*/
#include "../subghz_i.h"
#include "../views/subghz_car_emulate.h"
#include "../helpers/subghz_button_labels.h"
#include "../helpers/subghz_custom_event.h"
#include <lib/subghz/blocks/generic.h>
#include <notification/notification_messages.h>
#include "../helpers/subghz_txrx_i.h"
#include <lib/subghz/blocks/custom_btn_i.h>
#include <string.h>
#define TAG "SubGhzSceneCarEmulate"
#define MIN_TX_TICKS 66U /* ~666 ms at 100 ms tick */
@@ -29,6 +31,9 @@ typedef struct {
uint32_t freq;
char preset_short[12]; /* "AM650", "FM476", … */
bool has_serial;
bool has_counter;
/* TX state */
bool is_transmitting;
bool stop_pending; /* stop requested before MIN_TX_TICKS elapsed */
@@ -36,6 +41,7 @@ typedef struct {
/* Resolved custom button repeated while the physical key is held */
uint8_t pending_button;
uint8_t max_custom_button;
} CarEmulateState;
static CarEmulateState* s_state = NULL;
@@ -201,8 +207,101 @@ static void car_emulate_read_freq_preset(SubGhz* subghz, CarEmulateState* st) {
furi_string_free(preset_str);
}
static bool car_emulate_hex_digit(char c, uint8_t* value) {
if(c >= '0' && c <= '9') {
*value = c - '0';
return true;
} else if(c >= 'a' && c <= 'f') {
*value = c - 'a' + 10;
return true;
} else if(c >= 'A' && c <= 'F') {
*value = c - 'A' + 10;
return true;
}
return false;
}
static bool car_emulate_parse_hex_after(
const char* text,
const char* marker,
uint32_t* value) {
const char* field = strstr(text, marker);
if(!field) return false;
field += strlen(marker);
while(*field == ' ' || *field == '\t' || *field == '[') {
field++;
}
if(field[0] == '0' && (field[1] == 'x' || field[1] == 'X')) {
field += 2;
}
uint32_t parsed = 0;
uint8_t digits = 0;
uint8_t digit_value = 0;
while((digits < 8) && car_emulate_hex_digit(*field, &digit_value)) {
parsed = (parsed << 4) | digit_value;
digits++;
field++;
}
if(digits == 0) return false;
*value = parsed;
return true;
}
static bool car_emulate_serial_is_placeholder(void) {
return !s_state->has_serial || (s_state->serial <= 1);
}
static bool car_emulate_counter_is_placeholder(void) {
return !s_state->has_counter || (s_state->current_counter == 0);
}
static void car_emulate_set_counter(uint32_t value) {
s_state->original_counter = value;
s_state->current_counter = value;
s_state->has_counter = true;
}
static void car_emulate_apply_global_metadata(void) {
furi_assert(s_state);
if(subghz_block_generic_global.cnt_is_available) {
car_emulate_set_counter(subghz_block_generic_global.current_cnt);
}
}
static void car_emulate_apply_decoded_metadata(FuriString* decoded_text) {
furi_assert(s_state);
if(!decoded_text) return;
const char* text = furi_string_get_cstr(decoded_text);
uint32_t value = 0;
if((car_emulate_parse_hex_after(text, "Sn:", &value) ||
car_emulate_parse_hex_after(text, "SN:", &value) ||
car_emulate_parse_hex_after(text, "Ser:", &value) ||
car_emulate_parse_hex_after(text, "Serial:", &value)) &&
(value != 0)) {
s_state->serial = value;
s_state->has_serial = true;
} else if(
car_emulate_serial_is_placeholder() &&
car_emulate_parse_hex_after(text, "Fix:", &value) &&
(value != 0)) {
s_state->serial = value;
s_state->has_serial = true;
}
if(car_emulate_parse_hex_after(text, "Cnt:", &value) &&
((value != 0) || car_emulate_counter_is_placeholder())) {
car_emulate_set_counter(value);
}
}
/** Update Btn and Cnt fields in fff_data so the transmitter re-serialises them. */
static void car_emulate_apply_button(SubGhz* subghz, InputKey key) {
static bool car_emulate_apply_button(SubGhz* subghz, InputKey key) {
UNUSED(subghz);
uint8_t custom_btn_id;
@@ -215,7 +314,11 @@ static void car_emulate_apply_button(SubGhz* subghz, InputKey key) {
default: custom_btn_id = SUBGHZ_CUSTOM_BTN_OK; break;
}
subghz_custom_btn_set(custom_btn_id);
if(custom_btn_id > s_state->max_custom_button) {
return false;
}
return subghz_custom_btn_set(custom_btn_id);
}
@@ -331,7 +434,8 @@ void subghz_scene_car_emulate_on_enter(void* context) {
}
flipper_format_rewind(fff);
flipper_format_read_uint32(fff, "Serial", &s_state->serial, 1);
s_state->has_serial =
flipper_format_read_uint32(fff, "Serial", &s_state->serial, 1);
flipper_format_rewind(fff);
uint32_t btn_tmp = 0;
@@ -340,7 +444,8 @@ void subghz_scene_car_emulate_on_enter(void* context) {
}
flipper_format_rewind(fff);
flipper_format_read_uint32(fff, "Cnt", &s_state->original_counter, 1);
s_state->has_counter =
flipper_format_read_uint32(fff, "Cnt", &s_state->original_counter, 1);
s_state->current_counter = s_state->original_counter;
furi_string_free(tmp);
@@ -356,24 +461,58 @@ void subghz_scene_car_emulate_on_enter(void* context) {
* - subghz_custom_btn_get_original() → the button that was in the file
* - subghz_custom_btn_is_allowed() → true if protocol supports it
* - subghz_custom_btn_get_max() → number of buttons available */
subghz_block_generic_global_reset(NULL);
subghz_custom_btns_reset();
SubGhzProtocolDecoderBase* decoder = subghz_txrx_get_decoder(subghz->txrx);
if(decoder && fff) {
flipper_format_rewind(fff);
subghz_protocol_decoder_base_deserialize(decoder, fff);
if(subghz_protocol_decoder_base_deserialize(decoder, fff) == SubGhzProtocolStatusOk) {
FuriString* decoded_text = furi_string_alloc();
subghz_protocol_decoder_base_get_string(decoder, decoded_text);
car_emulate_apply_global_metadata();
car_emulate_apply_decoded_metadata(decoded_text);
furi_string_free(decoded_text);
}
/* Rewind again so subsequent reads in car_emulate_read_freq_preset()
* start from the beginning of the file. */
flipper_format_rewind(fff);
}
subghz_car_emulate_view_set_labels(
s_state->max_custom_button =
subghz_custom_btn_is_allowed() ? subghz_custom_btn_get_max() : SUBGHZ_CUSTOM_BTN_OK;
if(s_state->max_custom_button == SUBGHZ_CUSTOM_BTN_OK) {
s_state->max_custom_button =
subghz_button_labels_get_max_custom_btn(s_state->protocol_name);
if(s_state->max_custom_button != SUBGHZ_CUSTOM_BTN_OK) {
subghz_custom_btn_set_max(s_state->max_custom_button);
}
}
const char* button_labels[SUBGHZ_BUTTON_LABEL_COUNT];
subghz_button_labels_reset(button_labels);
subghz_button_labels_apply_protocol(s_state->protocol_name, button_labels);
subghz_car_emulate_view_set_labels(
subghz->car_emulate_view,
"UNLOCK", /* OK */
"LOCK", /* Up */
"TRUNK", /* Down */
"PANIC", /* Left */
"START" /* Right */
subghz_button_labels_get(
button_labels, SUBGHZ_CUSTOM_BTN_OK, subghz_custom_btn_get_original()),
s_state->max_custom_button >= SUBGHZ_CUSTOM_BTN_UP ?
subghz_button_labels_get(
button_labels, SUBGHZ_CUSTOM_BTN_UP, subghz_custom_btn_get_original()) :
"",
s_state->max_custom_button >= SUBGHZ_CUSTOM_BTN_DOWN ?
subghz_button_labels_get(
button_labels, SUBGHZ_CUSTOM_BTN_DOWN, subghz_custom_btn_get_original()) :
"",
s_state->max_custom_button >= SUBGHZ_CUSTOM_BTN_LEFT ?
subghz_button_labels_get(
button_labels, SUBGHZ_CUSTOM_BTN_LEFT, subghz_custom_btn_get_original()) :
"",
s_state->max_custom_button >= SUBGHZ_CUSTOM_BTN_RIGHT ?
subghz_button_labels_get(
button_labels, SUBGHZ_CUSTOM_BTN_RIGHT, subghz_custom_btn_get_original()) :
""
);
car_emulate_read_freq_preset(subghz, s_state);
@@ -407,14 +546,18 @@ bool subghz_scene_car_emulate_on_event(void* context, SceneManagerEvent event) {
car_emulate_stop_tx(subghz);
}
/* Bump counter */
s_state->current_counter++;
/* Set the custom button BEFORE deserialize() is called inside
* subghz_tx_start() → subghz_txrx_tx_start().
* The protocol's deserialize() will call subghz_custom_btn_get()
* to pick the right button code. */
car_emulate_apply_button(subghz, key);
if(!car_emulate_apply_button(subghz, key)) {
notification_message(subghz->notifications, &sequence_error);
car_emulate_refresh_view(subghz);
return true;
}
/* Bump counter */
s_state->current_counter++;
/* Only update the counter in fff_data; the protocol handles Btn. */
car_emulate_update_fff(subghz, s_state->current_counter);
@@ -1,105 +1,68 @@
#include "../subghz_i.h"
#include <lib/subghz/subghz_protocol_registry.h>
#include <stdlib.h>
#define TAG "SubGhzSceneProtocolList"
/* ── helpers ──────────────────────────────────────────────────────────────── */
typedef struct {
SubGhz* subghz;
const char* protocol_name;
} SubGhzProtocolListItemContext;
static bool proto_filter_contains(const char* filter, const char* name) {
const char* p = filter;
while(*p) {
const char* comma = strchr(p, ',');
size_t len = comma ? (size_t)(comma - p) : strlen(p);
if(len == strlen(name) && strncmp(p, name, len) == 0) return true;
if(!comma) break;
p = comma + 1;
}
return false;
static SubGhzProtocolListItemContext* protocol_list_item_contexts = NULL;
static void subghz_scene_protocol_list_free_contexts(void) {
free(protocol_list_item_contexts);
protocol_list_item_contexts = NULL;
}
static void proto_filter_toggle(char* filter, size_t filter_size, const char* name) {
if(proto_filter_contains(filter, name)) {
/* remove it */
char tmp[256] = {0};
const char* p = filter;
bool first = true;
while(*p) {
const char* comma = strchr(p, ',');
size_t len = comma ? (size_t)(comma - p) : strlen(p);
if(!(len == strlen(name) && strncmp(p, name, len) == 0)) {
if(!first) strncat(tmp, ",", sizeof(tmp) - strlen(tmp) - 1);
strncat(tmp, p, len < sizeof(tmp) - strlen(tmp) - 1 ? len : 0);
first = false;
}
if(!comma) break;
p = comma + 1;
}
strncpy(filter, tmp, filter_size - 1);
filter[filter_size - 1] = '\0';
} else {
/* add it */
if(filter[0] != '\0') strncat(filter, ",", filter_size - strlen(filter) - 1);
strncat(filter, name, filter_size - strlen(filter) - 1);
}
}
/* ── callbacks ────────────────────────────────────────────────────────────── */
static void subghz_scene_protocol_list_item_changed(VariableItem* item) {
SubGhz* subghz = variable_item_get_context(item);
uint8_t value_index = variable_item_get_current_value_index(item);
SubGhzProtocolListItemContext* item_context = variable_item_get_context(item);
if(!item_context || !item_context->subghz || !item_context->protocol_name) return;
uint32_t proto_idx =
scene_manager_get_scene_state(subghz->scene_manager, SubGhzSceneProtocolList);
size_t selected =
variable_item_list_get_selected_item_index(subghz->variable_item_list);
UNUSED(proto_idx);
SubGhz* subghz = item_context->subghz;
bool should_disable = variable_item_get_current_value_index(item) == 1;
const SubGhzProtocol* protocol =
subghz_protocol_registry_get_by_index(&subghz_protocol_registry, selected);
if(!protocol) return;
bool changed = subghz_last_settings_protocol_filter_set(
subghz->last_settings, item_context->protocol_name, should_disable);
bool is_disabled = subghz_last_settings_protocol_filter_contains(
subghz->last_settings, item_context->protocol_name);
const char* name = protocol->name;
bool currently_in =
proto_filter_contains(subghz->last_settings->protocol_filter, name);
if((value_index == 1) != currently_in) {
proto_filter_toggle(
subghz->last_settings->protocol_filter,
sizeof(subghz->last_settings->protocol_filter),
name);
}
variable_item_set_current_value_text(item, value_index ? "ONLY" : "---");
subghz_last_settings_save(subghz->last_settings);
variable_item_set_current_value_index(item, is_disabled ? 1 : 0);
variable_item_set_current_value_text(item, is_disabled ? "OFF" : "ON");
if(changed) subghz_last_settings_save(subghz->last_settings);
}
/* ── scene callbacks ──────────────────────────────────────────────────────── */
void subghz_scene_protocol_list_on_enter(void* context) {
SubGhz* subghz = context;
VariableItemList* list = subghz->variable_item_list;
variable_item_list_reset(list);
subghz_scene_protocol_list_free_contexts();
size_t protocol_count = subghz_protocol_registry_count(&subghz_protocol_registry);
protocol_list_item_contexts =
malloc(sizeof(SubGhzProtocolListItemContext) * protocol_count);
furi_check(protocol_list_item_contexts);
for(size_t i = 0; i < protocol_count; i++) {
const SubGhzProtocol* protocol =
subghz_protocol_registry_get_by_index(&subghz_protocol_registry, i);
if(!protocol) continue;
protocol_list_item_contexts[i].subghz = subghz;
protocol_list_item_contexts[i].protocol_name = protocol->name;
VariableItem* item = variable_item_list_add(
list,
protocol->name,
2,
subghz_scene_protocol_list_item_changed,
subghz);
&protocol_list_item_contexts[i]);
bool enabled = proto_filter_contains(
subghz->last_settings->protocol_filter, protocol->name);
variable_item_set_current_value_index(item, enabled ? 1 : 0);
variable_item_set_current_value_text(item, enabled ? "ONLY" : "---");
bool is_disabled =
subghz_last_settings_protocol_filter_contains(subghz->last_settings, protocol->name);
variable_item_set_current_value_index(item, is_disabled ? 1 : 0);
variable_item_set_current_value_text(item, is_disabled ? "OFF" : "ON");
}
variable_item_list_set_selected_item(
@@ -132,4 +95,5 @@ void subghz_scene_protocol_list_on_exit(void* context) {
SubGhzSceneProtocolList,
variable_item_list_get_selected_item_index(subghz->variable_item_list));
variable_item_list_reset(subghz->variable_item_list);
subghz_scene_protocol_list_free_contexts();
}
@@ -105,27 +105,10 @@ static void subghz_scene_add_to_history_callback(
SubGhz* subghz = context;
// The check can be moved to /lib/subghz/receiver.c, but may result in false positives
/* Protocol name allowlist filter — if non-empty, drop anything not in the list */
if(subghz->last_settings->protocol_filter[0] != '\0') {
const char* proto_name = decoder_base->protocol->name;
const char* filter = subghz->last_settings->protocol_filter;
bool allowed = false;
/* Walk the comma-separated list */
const char* p = filter;
while(*p) {
const char* comma = strchr(p, ',');
size_t len = comma ? (size_t)(comma - p) : strlen(p);
if(len == strlen(proto_name) && strncmp(p, proto_name, len) == 0) {
allowed = true;
break;
}
if(!comma) break;
p = comma + 1;
}
if(!allowed) {
FURI_LOG_D(TAG, "%s filtered by allowlist", proto_name);
return;
}
if(subghz_last_settings_protocol_filter_contains(
subghz->last_settings, decoder_base->protocol->name)) {
FURI_LOG_D(TAG, "%s filtered by protocol OFF list", decoder_base->protocol->name);
return;
}
if((decoder_base->protocol->flag & subghz->ignore_filter) == 0) {
@@ -679,15 +679,13 @@ void subghz_scene_receiver_config_on_enter(void* context) {
1,
NULL,
subghz);
if(subghz->last_settings->protocol_filter[0] == '\0') {
variable_item_set_current_value_text(item, "All");
uint8_t protocol_filter_count =
subghz_last_settings_protocol_filter_count(subghz->last_settings);
if(protocol_filter_count == 0) {
variable_item_set_current_value_text(item, "All ON");
} else {
uint8_t count = 1;
for(const char* p = subghz->last_settings->protocol_filter; *p; p++) {
if(*p == ',') count++;
}
static char filter_count_str[8];
snprintf(filter_count_str, sizeof(filter_count_str), "%u set", count);
snprintf(filter_count_str, sizeof(filter_count_str), "%u OFF", protocol_filter_count);
variable_item_set_current_value_text(item, filter_count_str);
}
@@ -121,17 +121,7 @@ bool subghz_scene_saved_menu_on_event(void* context, SceneManagerEvent event) {
scene_manager_set_scene_state(
subghz->scene_manager, SubGhzSceneSavedMenu, SubmenuIndexEmulate);
bool use_custom = subghz->last_settings->custom_car_emulate;
if(use_custom) {
FlipperFormat* fff = subghz_txrx_get_fff_data(subghz->txrx);
uint32_t cnt_tmp = 0;
flipper_format_rewind(fff);
if(!flipper_format_read_uint32(fff, "Cnt", &cnt_tmp, 1)) {
use_custom = false;
}
}
if(use_custom) {
if(subghz->last_settings->custom_car_emulate) {
scene_manager_next_scene(subghz->scene_manager, SubGhzSceneCarEmulate);
} else {
scene_manager_next_scene(subghz->scene_manager, SubGhzSceneTransmitter);
@@ -1,5 +1,6 @@
#include "../subghz_i.h"
#include "subghz/types.h"
#include "../helpers/subghz_button_labels.h"
#include "../helpers/subghz_custom_event.h"
#include <lib/toolbox/value_index.h>
#include <machine/endian.h>
@@ -7,6 +8,7 @@
#include <lib/subghz/blocks/generic.h>
#include <lib/subghz/blocks/custom_btn_i.h>
#include <string.h>
#include <stdio.h>
#define TAG "SubGhzSceneSignalSettings"
@@ -15,6 +17,8 @@ static uint32_t counter32 = 0x0;
static uint16_t counter16 = 0x0;
static uint8_t cnt_byte_count = 0;
static uint8_t* cnt_byte_ptr = NULL;
static uint32_t counter_value = 0;
static uint32_t counter_mask = 0xffffffffUL;
static FuriString* byte_input_text;
@@ -26,12 +30,24 @@ static uint8_t submenu_called = 0;
static bool button_uses_custom_btn = false;
static uint8_t button_custom_id = SUBGHZ_CUSTOM_BTN_OK;
static bool subghz_scene_signal_settings_rebuild_save_reload(
SubGhz* subghz,
bool use_custom_btn,
uint8_t custom_btn_id);
enum {
SignalSettingsIndexCounterMode,
SignalSettingsIndexCounter,
SignalSettingsIndexButton,
};
enum {
SignalSettingsCounterStepDown,
SignalSettingsCounterStepValue,
SignalSettingsCounterStepUp,
SignalSettingsCounterStepCount,
};
#define COUNTER_MODE_COUNT 8
static const char* const counter_mode_text[COUNTER_MODE_COUNT] = {
"System",
@@ -66,18 +82,7 @@ static const uint8_t button_value[] = {
7,
};
#define BUTTON_VALUE_COUNT (sizeof(button_value) / sizeof(button_value[0]))
static const char* const button_default_labels[BUTTON_VALUE_COUNT] = {
"Original",
"Up",
"Down",
"Left",
"Right",
"Button 5",
"Button 6",
"Button 7",
};
#define BUTTON_VALUE_COUNT SUBGHZ_BUTTON_LABEL_COUNT
static const char* button_labels[BUTTON_VALUE_COUNT] = {
"Original",
@@ -90,30 +95,6 @@ static const char* button_labels[BUTTON_VALUE_COUNT] = {
"Button 7",
};
typedef struct {
const char* protocol;
const char* labels[BUTTON_VALUE_COUNT];
} ProtocolButtonLabels;
static const ProtocolButtonLabels protocol_button_labels[] = {
{"VAG GROUP", {"Original", "Lock", "Unlock", "Trunk", "Panic"}},
{"Porsche AG", {"Original", "Lock", "Unlock", "Trunk", "Open"}},
{"FORD V0", {"Original", "Lock", "Unlock", "Trunk"}},
{"Ford V2", {"Unlock", "Lock", "Trunk", "Panic", "Remote Start"}},
{"PSA GROUP", {"Original", "Lock", "Unlock", "Trunk", "Trunk"}},
{"KIA/HYU V0", {"Original", "Lock", "Unlock", "Trunk", "Horn"}},
{"KIA/HYU V1", {"Original", "Lock", "Unlock", "Trunk", "Panic"}},
{"KIA/HYU V2", {"Original", "Lock", "Unlock", "Trunk", "Panic"}},
{"KIA/HYU V3/V4", {"Original", "Lock", "Unlock", "Trunk", "Panic", "Horn"}},
{"KIA/HYU V5", {"Original", "Unlock", "Lock", "Trunk", "Horn"}},
{"KIA/HYU V6", {"Original", "Lock", "Unlock", "Trunk", "Panic"}},
{"SUBARU", {"Original", "Lock", "Unlock", "Trunk", "Panic", "Extra"}},
{"SUZUKI", {"Original", "Lock", "Unlock", "Trunk", "Panic"}},
{"Star Line", {"Original", "Lock", "Unlock", "Trunk", "Start"}},
{"Scher-Khan", {"Original", "Lock", "Unlock", "Trunk", "Start"}},
{"Sheriff CFM", {"Original", "Lock", "Unlock", "Trunk", "Panic"}},
};
typedef struct {
char* name;
uint8_t mode_count;
@@ -131,35 +112,16 @@ static Protocols protocols[] = {
#define PROTOCOLS_COUNT (sizeof(protocols) / sizeof(Protocols))
static void subghz_scene_signal_settings_reset_button_labels(void) {
for(uint8_t i = 0; i < BUTTON_VALUE_COUNT; i++) {
button_labels[i] = button_default_labels[i];
}
subghz_button_labels_reset(button_labels);
}
static void subghz_scene_signal_settings_apply_button_labels(const char* protocol) {
for(uint8_t i = 0; i < COUNT_OF(protocol_button_labels); i++) {
if(strcmp(protocol, protocol_button_labels[i].protocol) == 0) {
for(uint8_t btn = 0; btn < BUTTON_VALUE_COUNT; btn++) {
if(protocol_button_labels[i].labels[btn]) {
button_labels[btn] = protocol_button_labels[i].labels[btn];
}
}
break;
}
}
subghz_button_labels_apply_protocol(protocol, button_labels);
}
static const char* subghz_scene_signal_settings_get_button_label(uint8_t custom_btn_id) {
if(custom_btn_id == SUBGHZ_CUSTOM_BTN_OK) {
uint8_t original = subghz_custom_btn_get_original();
if((original != SUBGHZ_CUSTOM_BTN_OK) && (original < BUTTON_VALUE_COUNT)) {
return button_labels[original];
}
}
if(custom_btn_id < BUTTON_VALUE_COUNT) {
return button_labels[custom_btn_id];
}
return "Button";
return subghz_button_labels_get(
button_labels, custom_btn_id, subghz_custom_btn_get_original());
}
static bool subghz_scene_signal_settings_update_uint32_field(
@@ -170,6 +132,60 @@ static bool subghz_scene_signal_settings_update_uint32_field(
return flipper_format_insert_or_update_uint32(fff, key, &value, 1);
}
static uint32_t subghz_scene_signal_settings_counter_get_mask(void) {
uint8_t bits = subghz_block_generic_global.cnt_length_bit;
if((bits == 0) || (bits >= 32)) {
return 0xffffffffUL;
}
return (1UL << bits) - 1UL;
}
static void subghz_scene_signal_settings_counter_sync_byte_input(void) {
if(cnt_byte_count == 4) {
counter32 = __bswap32(counter_value);
cnt_byte_ptr = (uint8_t*)&counter32;
} else if(cnt_byte_count == 2) {
counter16 = __bswap16((uint16_t)counter_value);
cnt_byte_ptr = (uint8_t*)&counter16;
}
}
static void subghz_scene_signal_settings_counter_set_value(uint32_t value) {
counter_value = value & counter_mask;
subghz_scene_signal_settings_counter_sync_byte_input();
}
static void subghz_scene_signal_settings_counter_format(FuriString* text) {
if(cnt_byte_count == 4) {
furi_string_printf(text, "%lX", (unsigned long)counter_value);
} else {
furi_string_printf(text, "%X", (unsigned int)(counter_value & 0xffffU));
}
}
static void subghz_scene_signal_settings_counter_update_item(VariableItem* item) {
char text[9] = {0};
variable_item_set_current_value_index(item, SignalSettingsCounterStepValue);
if(cnt_byte_count == 4) {
snprintf(text, sizeof(text), "%lX", (unsigned long)counter_value);
} else {
snprintf(text, sizeof(text), "%X", (unsigned int)(counter_value & 0xffffU));
}
variable_item_set_current_value_text(item, text);
}
static bool subghz_scene_signal_settings_counter_save(SubGhz* subghz) {
FlipperFormat* fff = subghz_txrx_get_fff_data(subghz->txrx);
if(!subghz_scene_signal_settings_update_uint32_field(fff, "Cnt", counter_value)) {
FURI_LOG_E(TAG, "Error update/insert Cnt value");
dialog_message_show_storage_error(subghz->dialogs, "Cannot save\ncounter");
return false;
}
subghz_block_generic_global_counter_override_set(counter_value);
return subghz_scene_signal_settings_rebuild_save_reload(subghz, false, SUBGHZ_CUSTOM_BTN_OK);
}
static bool subghz_scene_signal_settings_hex_digit(char c, uint8_t* value) {
if(c >= '0' && c <= '9') {
*value = c - '0';
@@ -332,6 +348,28 @@ void subghz_scene_signal_settings_counter_mode_changed(VariableItem* item) {
}
}
void subghz_scene_signal_settings_counter_changed(VariableItem* item) {
if(!cnt_byte_ptr || cnt_byte_count == 0) return;
uint8_t index = variable_item_get_current_value_index(item);
if(index == SignalSettingsCounterStepValue) {
subghz_scene_signal_settings_counter_update_item(item);
return;
}
if(index == SignalSettingsCounterStepUp) {
subghz_scene_signal_settings_counter_set_value(counter_value + 1);
} else {
subghz_scene_signal_settings_counter_set_value(counter_value - 1);
}
subghz_scene_signal_settings_counter_update_item(item);
SubGhz* subghz = variable_item_get_context(item);
furi_assert(subghz);
subghz_scene_signal_settings_counter_save(subghz);
}
void subghz_scene_signal_settings_button_changed(VariableItem* item) {
uint8_t index = variable_item_get_current_value_index(item);
if(index >= BUTTON_VALUE_COUNT) index = 0;
@@ -407,6 +445,8 @@ void subghz_scene_signal_settings_on_enter(void* context) {
counter16 = 0;
cnt_byte_count = 0;
cnt_byte_ptr = NULL;
counter_value = 0;
counter_mask = 0xffffffffUL;
button = 0;
btn_byte_count = 1;
btn_byte_ptr = NULL;
@@ -508,26 +548,26 @@ void subghz_scene_signal_settings_on_enter(void* context) {
FURI_LOG_D(TAG, "Counter mode and edit not available for this protocol");
} else {
counter_not_available = false;
counter_mask = subghz_scene_signal_settings_counter_get_mask();
// Check is there byte_count more than 2 hex bytes long or not
// To show hex value we must correct revert bytes for ByteInput view with __bswapХХ
// ByteInput stores the visible hex value as big-endian bytes.
if(subghz_block_generic_global.cnt_length_bit > 16) {
counter32 = subghz_block_generic_global.current_cnt;
furi_string_printf(tmp_text, "%lX", counter32);
counter32 = __bswap32(counter32);
cnt_byte_ptr = (uint8_t*)&counter32;
cnt_byte_count = 4;
} else {
counter16 = subghz_block_generic_global.current_cnt;
furi_string_printf(tmp_text, "%X", counter16);
counter16 = __bswap16(counter16);
cnt_byte_ptr = (uint8_t*)&counter16;
cnt_byte_count = 2;
}
subghz_scene_signal_settings_counter_set_value(subghz_block_generic_global.current_cnt);
subghz_scene_signal_settings_counter_format(tmp_text);
}
item = variable_item_list_add(variable_item_list, "Edit Counter", 1, NULL, subghz);
variable_item_set_current_value_index(item, 0);
item = variable_item_list_add(
variable_item_list,
"Edit Counter",
counter_not_available ? 1 : SignalSettingsCounterStepCount,
counter_not_available ? NULL : subghz_scene_signal_settings_counter_changed,
subghz);
variable_item_set_current_value_index(
item, counter_not_available ? 0 : SignalSettingsCounterStepValue);
variable_item_set_current_value_text(item, furi_string_get_cstr(tmp_text));
variable_item_set_locked(item, (counter_not_available), "Not available\nfor this\nprotocol !");
//
@@ -590,17 +630,13 @@ bool subghz_scene_signal_settings_on_event(void* context, SceneManagerEvent even
switch(cnt_byte_count) {
case 2:
counter16 = __bswap16(counter16);
subghz_scene_signal_settings_update_uint32_field(fff, "Cnt", counter16);
subghz_block_generic_global_counter_override_set(counter16);
subghz_scene_signal_settings_rebuild_save_reload(
subghz, false, SUBGHZ_CUSTOM_BTN_OK);
subghz_scene_signal_settings_counter_set_value(counter16);
subghz_scene_signal_settings_counter_save(subghz);
break;
case 4:
counter32 = __bswap32(counter32);
subghz_scene_signal_settings_update_uint32_field(fff, "Cnt", counter32);
subghz_block_generic_global_counter_override_set(counter32);
subghz_scene_signal_settings_rebuild_save_reload(
subghz, false, SUBGHZ_CUSTOM_BTN_OK);
subghz_scene_signal_settings_counter_set_value(counter32);
subghz_scene_signal_settings_counter_save(subghz);
break;
default:
break;
@@ -23,7 +23,7 @@
#define SUBGHZ_LAST_SETTING_FIELD_LED_AND_POWER_AMP "LedAndPowerAmp"
#define SUBGHZ_LAST_SETTING_FIELD_TX_POWER "TXPower"
#define SUBGHZ_LAST_SETTING_FIELD_CUSTOM_CAR_EMULATE "CustomCarEmulate"
#define SUBGHZ_LAST_SETTING_FIELD_PROTOCOL_FILTER "ProtocolFilter"
#define SUBGHZ_LAST_SETTING_FIELD_PROTOCOL_FILTER "ProtocolFilterOff"
SubGhzLastSettings* subghz_last_settings_alloc(void) {
SubGhzLastSettings* instance = malloc(sizeof(SubGhzLastSettings));
@@ -187,6 +187,7 @@ void subghz_last_settings_load(SubGhzLastSettings* instance, size_t preset_count
flipper_format_rewind(fff_data_file);
}
furi_string_free(filter_str);
subghz_last_settings_protocol_filter_normalize(instance);
} while(0);
} else {
+190 -1
View File
@@ -3,6 +3,7 @@
#include <furi_hal.h>
#include <stdint.h>
#include <stdbool.h>
#include <string.h>
#include <storage/storage.h>
#include <lib/subghz/types.h>
@@ -13,6 +14,7 @@
#define SUBGHZ_LAST_SETTING_DEFAULT_FREQUENCY 433920000
#define SUBGHZ_LAST_SETTING_FREQUENCY_ANALYZER_FEEDBACK_LEVEL 2
#define SUBGHZ_LAST_SETTING_DEFAULT_PRESET_HOPPING_THRESHOLD (-80.0f)
#define SUBGHZ_LAST_SETTINGS_PROTOCOL_FILTER_SIZE 1024
typedef struct {
uint32_t frequency;
@@ -31,9 +33,196 @@ typedef struct {
bool leds_and_amp;
uint8_t tx_power;
bool custom_car_emulate;
char protocol_filter[256]; /* comma-separated allowlist, empty = disabled */
char protocol_filter[SUBGHZ_LAST_SETTINGS_PROTOCOL_FILTER_SIZE]; /* comma-separated disabled protocols, empty = all enabled */
} SubGhzLastSettings;
static inline void subghz_last_settings_protocol_filter_next_token(
const char** cursor,
const char** token,
size_t* token_len) {
const char* start = *cursor;
while((*start == ',') || (*start == ' ') || (*start == '\t')) {
start++;
}
const char* end = start;
while((*end != '\0') && (*end != ',')) {
end++;
}
const char* trim_end = end;
while((trim_end > start) && ((trim_end[-1] == ' ') || (trim_end[-1] == '\t'))) {
trim_end--;
}
*token = start;
*token_len = (size_t)(trim_end - start);
*cursor = (*end == ',') ? end + 1 : end;
}
static inline bool subghz_last_settings_protocol_filter_token_matches(
const char* token,
size_t token_len,
const char* name,
size_t name_len) {
return (token_len == name_len) && (strncmp(token, name, token_len) == 0);
}
static inline bool subghz_last_settings_protocol_filter_contains_token(
const char* filter,
const char* token,
size_t token_len) {
const char* cursor = filter;
const char* current = NULL;
size_t current_len = 0;
while(*cursor != '\0') {
subghz_last_settings_protocol_filter_next_token(&cursor, &current, &current_len);
if((current_len != 0) &&
subghz_last_settings_protocol_filter_token_matches(
current, current_len, token, token_len)) {
return true;
}
}
return false;
}
static inline bool subghz_last_settings_protocol_filter_append_token(
char* filter,
size_t filter_size,
const char* token,
size_t token_len) {
if(token_len == 0) return true;
size_t filter_len = strlen(filter);
size_t separator_len = filter_len == 0 ? 0 : 1;
if((filter_len + separator_len + token_len) >= filter_size) {
return false;
}
if(separator_len != 0) {
filter[filter_len++] = ',';
}
memcpy(&filter[filter_len], token, token_len);
filter[filter_len + token_len] = '\0';
return true;
}
static inline bool subghz_last_settings_protocol_filter_contains(
const SubGhzLastSettings* instance,
const char* protocol) {
if((instance == NULL) || (protocol == NULL) || (protocol[0] == '\0')) return false;
return subghz_last_settings_protocol_filter_contains_token(
instance->protocol_filter, protocol, strlen(protocol));
}
static inline bool subghz_last_settings_protocol_filter_normalize(
SubGhzLastSettings* instance) {
if(instance == NULL) return false;
char normalized[SUBGHZ_LAST_SETTINGS_PROTOCOL_FILTER_SIZE] = {0};
const char* cursor = instance->protocol_filter;
const char* token = NULL;
size_t token_len = 0;
while(*cursor != '\0') {
subghz_last_settings_protocol_filter_next_token(&cursor, &token, &token_len);
if((token_len == 0) ||
subghz_last_settings_protocol_filter_contains_token(normalized, token, token_len)) {
continue;
}
if(!subghz_last_settings_protocol_filter_append_token(
normalized, sizeof(normalized), token, token_len)) {
return false;
}
}
bool changed = strcmp(instance->protocol_filter, normalized) != 0;
if(changed) {
memcpy(instance->protocol_filter, normalized, sizeof(instance->protocol_filter));
}
return changed;
}
static inline bool subghz_last_settings_protocol_filter_set(
SubGhzLastSettings* instance,
const char* protocol,
bool disabled) {
if((instance == NULL) || (protocol == NULL) || (protocol[0] == '\0')) return false;
char updated[SUBGHZ_LAST_SETTINGS_PROTOCOL_FILTER_SIZE] = {0};
const char* cursor = instance->protocol_filter;
const char* token = NULL;
size_t token_len = 0;
const size_t protocol_len = strlen(protocol);
bool protocol_written = false;
while(*cursor != '\0') {
subghz_last_settings_protocol_filter_next_token(&cursor, &token, &token_len);
if(token_len == 0) continue;
bool is_target = subghz_last_settings_protocol_filter_token_matches(
token, token_len, protocol, protocol_len);
if(is_target) {
if(disabled && !protocol_written) {
if(!subghz_last_settings_protocol_filter_append_token(
updated, sizeof(updated), protocol, protocol_len)) {
return false;
}
protocol_written = true;
}
continue;
}
if(!subghz_last_settings_protocol_filter_contains_token(updated, token, token_len)) {
if(!subghz_last_settings_protocol_filter_append_token(
updated, sizeof(updated), token, token_len)) {
return false;
}
}
}
if(disabled && !protocol_written) {
if(!subghz_last_settings_protocol_filter_append_token(
updated, sizeof(updated), protocol, protocol_len)) {
return false;
}
}
bool changed = strcmp(instance->protocol_filter, updated) != 0;
if(changed) {
memcpy(instance->protocol_filter, updated, sizeof(instance->protocol_filter));
}
return changed;
}
static inline uint8_t subghz_last_settings_protocol_filter_count(
const SubGhzLastSettings* instance) {
if(instance == NULL) return 0;
char seen[SUBGHZ_LAST_SETTINGS_PROTOCOL_FILTER_SIZE] = {0};
const char* cursor = instance->protocol_filter;
const char* token = NULL;
size_t token_len = 0;
uint8_t count = 0;
while(*cursor != '\0') {
subghz_last_settings_protocol_filter_next_token(&cursor, &token, &token_len);
if((token_len == 0) ||
subghz_last_settings_protocol_filter_contains_token(seen, token, token_len)) {
continue;
}
if(!subghz_last_settings_protocol_filter_append_token(seen, sizeof(seen), token, token_len)) {
break;
}
count++;
}
return count;
}
SubGhzLastSettings* subghz_last_settings_alloc(void);
void subghz_last_settings_free(SubGhzLastSettings* instance);
@@ -17,11 +17,11 @@ typedef struct {
char preset[12];
bool is_transmitting;
uint8_t anim_frame;
char label_ok[12];
char label_up[12];
char label_down[12];
char label_left[12];
char label_right[12];
char label_ok[16];
char label_up[16];
char label_down[16];
char label_left[16];
char label_right[16];
} SubGhzCarEmulateViewModel;
/* ── Handle ─────────────────────────────────────────────────────────────── */
@@ -79,7 +79,7 @@ static void subghz_car_emulate_view_draw(Canvas* canvas, void* model_ptr) {
const uint8_t font_h = canvas_current_font_height(canvas);
/* Centre → UNLOCK (OK button) */
{
if(m->label_ok[0]) {
const char* lbl = m->label_ok;
uint8_t w = (uint8_t)(canvas_string_width(canvas, lbl) + 8U);
canvas_draw_rbox(canvas, 64 - w / 2, 45 - font_h / 2, w, font_h, 3);
@@ -89,7 +89,7 @@ static void subghz_car_emulate_view_draw(Canvas* canvas, void* model_ptr) {
}
/* Up → LOCK */
{
if(m->label_up[0]) {
const char* lbl = m->label_up;
uint8_t w = (uint8_t)(canvas_string_width(canvas, lbl) + 8U);
canvas_draw_rbox(canvas, 64 - w / 2, 33 - font_h / 2, w, font_h, 3);
@@ -99,7 +99,7 @@ static void subghz_car_emulate_view_draw(Canvas* canvas, void* model_ptr) {
}
/* Left → PANIC */
{
if(m->label_left[0]) {
const char* lbl = m->label_left;
uint8_t w = (uint8_t)(canvas_string_width(canvas, lbl) + 8U);
canvas_draw_rbox(canvas, 0, 46 - font_h / 2, w, font_h, 3);
@@ -109,7 +109,7 @@ static void subghz_car_emulate_view_draw(Canvas* canvas, void* model_ptr) {
}
/* Right → generic extra */
{
if(m->label_right[0]) {
const char* lbl = m->label_right;
uint8_t w = (uint8_t)(canvas_string_width(canvas, lbl) + 8U);
canvas_draw_rbox(canvas, 127 - w, 46 - font_h / 2, w, font_h, 3);
@@ -119,7 +119,7 @@ static void subghz_car_emulate_view_draw(Canvas* canvas, void* model_ptr) {
}
/* Down → BOOT */
{
if(m->label_down[0]) {
const char* lbl = m->label_down;
uint8_t w = (uint8_t)(canvas_string_width(canvas, lbl) + 8U);
canvas_draw_rbox(canvas, 64 - w / 2, 57 - font_h / 2, w, font_h, 3);
@@ -259,6 +259,11 @@ void subghz_car_emulate_view_set_labels(
strncpy(m->label_down, down ? down : "", sizeof(m->label_down) - 1);
strncpy(m->label_left, left ? left : "", sizeof(m->label_left) - 1);
strncpy(m->label_right, right ? right : "", sizeof(m->label_right) - 1);
m->label_ok[sizeof(m->label_ok) - 1] = '\0';
m->label_up[sizeof(m->label_up) - 1] = '\0';
m->label_down[sizeof(m->label_down) - 1] = '\0';
m->label_left[sizeof(m->label_left) - 1] = '\0';
m->label_right[sizeof(m->label_right) - 1] = '\0';
},
true);
}
+14 -4
View File
@@ -51,6 +51,10 @@ Navigate with **Up/Down**, activate with **OK**, close with **Back**.
| **Press SELECT** | Sends a Select press to the game and resumes |
| **Frameskip** | Change with **Left/Right**: `auto` (recommended), or fixed `04`. `auto` shows the skip level currently in use |
| **Sound** | Volume: cycle off/25/50/75/100% with **Left/Right**; **OK** toggles mute/full (`n/a` if the speaker is in use by another app) |
| **Scale** | `fit` (whole screen, squashed) or `1:1 crop` (pixel-perfect center crop) |
| **Dither** | `std` (2x2 ordered), `temporal` (alternating pattern: the LCD's slow pixels average it into perceived grayscale), `hi-con` (grays snap to black/white: crisper text) |
| **OK hold** | `A+Start`: holding OK ~0.7 s also sends Start (off by default: interferes with games that hold A) |
| **Save/Load state** | Instant save states (`.ss1` file next to the ROM) |
| **Save SRAM** | Writes the cartridge battery save (`.sav`) to the SD card immediately |
| **Exit** | Saves SRAM (if the cartridge has a battery) and quits the app |
@@ -141,10 +145,16 @@ bytes.
- The PPU only renders the 64 scanlines (out of 144) that survive the
downscale to the Flipper LCD — ~55% of the per-frame rendering work is
skipped with zero visual difference.
- ROM streaming works on 8 KB pages (half a MBC bank): twice the cache
slots per KB of heap and half the SD stall per miss compared to whole-bank
caching. Bank-switch heavy games may still micro-stutter on a cache miss
(only happens when the ROM doesn't fit in RAM).
- ROM streaming works on lazily-mapped 4 KB units (quarter banks): bank
switching itself is free, and data is only fetched when actually read.
Measured on Pokemon Red: the game rewrites its bank register ~200x per
frame while reading from ~17 units — eager fetching caused ~86 phantom
cache misses per frame; the lazy design reduces that to ~1.
- Input is read by polling the button GPIOs directly each frame: the OS
input-event service can be starved by a CPU-heavy game (a firmware
timer-daemon priority quirk) and used to wedge all input until reboot.
- Frame pacing runs on an absolute 59.73 Hz tick grid with auto-frameskip
hysteresis: constant-velocity motion stays judder-free.
- The emulator core is compiled `-O2` (the rest of the app stays `-Os`) and
the per-instruction hot path (opcode fetch, PPU/timer/APU ticks) is
manually inlined across the core, cutting per-instruction call overhead.
@@ -3,7 +3,7 @@ App(
name="FlipGB",
apptype=FlipperAppType.EXTERNAL,
entry_point="flipgb_app",
sources=["*.c*", "!hosttest", "!lib"],
sources=["*.c*", "!hosttest", "!lib", "!tests"],
requires=["gui", "dialogs", "storage"],
stack_size=8 * 1024,
cdefines=[("GB_FB_ROWS", "64")],
@@ -21,6 +21,6 @@ App(
fap_category="Games",
fap_icon="flipgb_icon.png",
fap_author="user",
fap_version="1.0",
fap_version="5.1",
fap_description="Game Boy (DMG) emulator. Loads .gb ROMs from the SD card, streams ROM banks, battery saves, adaptive frameskip.",
)
@@ -29,9 +29,9 @@ static void serial_hook(u8 byte) {
}
}
static const u8* bank_provider(void* /*ctx*/, uint page) {
long offset = static_cast<long>(page) * 0x2000; /* 8 KB ROM pages */
if(offset + 0x2000 > g_rom_size) offset = 0;
static const u8* bank_provider(void* /*ctx*/, uint unit) {
long offset = static_cast<long>(unit) * 0x1000; /* 4 KB ROM units */
if(offset + 0x1000 > g_rom_size) offset = 0;
return g_rom + offset;
}
@@ -99,11 +99,31 @@ int main(int argc, char** argv) {
bool rowmask = false;
bool dump_audio = false;
bool skiprender = false;
/* input script: --press <frame>:<BTN>:<hold_frames>, repeatable */
struct ScriptPress { long frame; GbButton btn; long hold; };
ScriptPress script[32];
int nscript = 0;
for(int i = 1; i < argc; i++) {
if(!strcmp(argv[i], "--dump-frame")) dump = true;
if(!strcmp(argv[i], "--rowmask")) rowmask = true;
if(!strcmp(argv[i], "--dump-audio")) dump_audio = true;
if(!strcmp(argv[i], "--skiprender")) skiprender = true;
if(!strcmp(argv[i], "--press") && i + 1 < argc && nscript < 32) {
char name[16];
long fr, hold;
if(sscanf(argv[++i], "%ld:%15[^:]:%ld", &fr, name, &hold) == 3) {
GbButton b = GbButton::A;
if(!strcmp(name, "A")) b = GbButton::A;
else if(!strcmp(name, "B")) b = GbButton::B;
else if(!strcmp(name, "START")) b = GbButton::Start;
else if(!strcmp(name, "SELECT")) b = GbButton::Select;
else if(!strcmp(name, "UP")) b = GbButton::Up;
else if(!strcmp(name, "DOWN")) b = GbButton::Down;
else if(!strcmp(name, "LEFT")) b = GbButton::Left;
else if(!strcmp(name, "RIGHT")) b = GbButton::Right;
script[nscript++] = {fr, b, hold};
}
}
}
/* Same 144 -> 64 line subsampling the Flipper frontend uses */
@@ -155,6 +175,10 @@ int main(int argc, char** argv) {
u8 last_vol = 0;
for(long frame = 0; frame < max_frames; frame++) {
for(int i = 0; i < nscript; i++) {
if(script[i].frame == frame) gb->button_pressed(script[i].btn);
if(script[i].frame + script[i].hold == frame) gb->button_released(script[i].btn);
}
gb->run_to_vblank();
if(dump_audio) {
@@ -1,3 +1,10 @@
/* Cold code: size-optimized on the embedded target. Every KB of binary
* is a KB of heap the ROM page cache loses (the FAP loads into RAM), and
* nothing in this file runs per-instruction. */
#if defined(__arm__) && defined(__GNUC__)
#pragma GCC optimize("Os")
#endif
#include "apu.h"
/* Slow path of Apu::tick (see apu.h): runs the due frame-sequencer steps */
@@ -44,6 +44,73 @@ public:
/* n: 0 = pulse 1, 1 = pulse 2, 2 = wave, 3 = noise */
void get_voice(uint n, ApuVoice* out) const;
/* save-state support: exact raw state (masked IO reads lose
* write-only bits like the frequency-low registers) */
struct State {
u8 regs[20]; /* ch[4] x nr0..nr4 */
u8 nr50, nr51, power;
u8 wave[16];
u8 enabled_mask;
u8 env_vol[4], env_tim[4];
u16 length[4];
u32 order[4];
u32 sweep_shadow;
u8 sweep_timer, sweep_en;
u32 seq_counter;
u8 seq_step;
u32 trig_counter;
};
void export_state(State* s) const {
for(uint n = 0; n < 4; n++) {
s->regs[n * 5 + 0] = ch[n].nr0;
s->regs[n * 5 + 1] = ch[n].nr1;
s->regs[n * 5 + 2] = ch[n].nr2;
s->regs[n * 5 + 3] = ch[n].nr3;
s->regs[n * 5 + 4] = ch[n].nr4;
s->env_vol[n] = ch[n].env_volume;
s->env_tim[n] = ch[n].env_timer;
s->length[n] = (u16)ch[n].length;
s->order[n] = ch[n].order;
if(ch[n].enabled) s->enabled_mask |= (u8)(1 << n);
}
s->nr50 = nr50;
s->nr51 = nr51;
s->power = power;
for(uint i = 0; i < 16; i++)
s->wave[i] = wave_ram[i];
s->sweep_shadow = sweep_shadow;
s->sweep_timer = sweep_timer;
s->sweep_en = sweep_enabled;
s->seq_counter = seq_counter;
s->seq_step = seq_step;
s->trig_counter = trigger_counter;
}
void import_state(const State* s) {
for(uint n = 0; n < 4; n++) {
ch[n].nr0 = s->regs[n * 5 + 0];
ch[n].nr1 = s->regs[n * 5 + 1];
ch[n].nr2 = s->regs[n * 5 + 2];
ch[n].nr3 = s->regs[n * 5 + 3];
ch[n].nr4 = s->regs[n * 5 + 4];
ch[n].env_volume = s->env_vol[n];
ch[n].env_timer = s->env_tim[n];
ch[n].length = s->length[n];
ch[n].order = s->order[n];
ch[n].enabled = (s->enabled_mask >> n) & 1;
}
nr50 = s->nr50;
nr51 = s->nr51;
power = s->power != 0;
for(uint i = 0; i < 16; i++)
wave_ram[i] = s->wave[i];
sweep_shadow = s->sweep_shadow;
sweep_timer = s->sweep_timer;
sweep_enabled = s->sweep_en != 0;
seq_counter = s->seq_counter;
seq_step = s->seq_step;
trigger_counter = s->trig_counter;
}
/* Master volume 0..7 (louder of the two NR50 output terminals) */
auto master_volume() const -> u8 {
u8 l = (nr50 >> 4) & 7;
@@ -1,3 +1,10 @@
/* Cold code: size-optimized on the embedded target. Every KB of binary
* is a KB of heap the ROM page cache loses (the FAP loads into RAM), and
* nothing in this file runs per-instruction. */
#if defined(__arm__) && defined(__GNUC__)
#pragma GCC optimize("Os")
#endif
#include "cartridge.h"
void Cartridge::init(
@@ -37,6 +44,11 @@ void Cartridge::update_rom_bank() {
bank = (bank_high << 5) | bank_low;
/* bank_low == 0 is translated to 1 at write time */
break;
case MBCType::MBC1M:
/* multicart wiring: only 4 low bits reach the ROM, the high
* register selects the sub-game */
bank = (bank_high << 4) | (bank_low & 0x0F);
break;
case MBCType::MBC2:
case MBCType::MBC3:
bank = bank_low;
@@ -52,24 +64,22 @@ void Cartridge::update_rom_bank() {
if(bank_count) bank %= bank_count;
/* Games (Pokemon's Bankswitch routine included) frequently rewrite the
* bank register with the bank that is already mapped: skip the cache
* lookups entirely in that case. */
/* Bank switching is FREE: quarters are unmapped and fetched lazily
* on first read. Games rewrite the bank register constantly without
* reading -- eager fetching here was ~86 phantom misses per frame. */
if(bank == cur_bank) return;
cur_bank = bank;
/* map the lo 8 KB page now; the hi page streams in lazily on first
* read from 0x6000-0x7FFF (Cartridge::read) */
bankN_lo = provider(provider_ctx, bank * 2);
bankN_hi = nullptr;
bankN_q[0] = bankN_q[1] = bankN_q[2] = bankN_q[3] = nullptr;
}
void Cartridge::fetch_hi_page() const {
/* Refresh the lo page's LRU stamp first so the hi fetch can never
* evict it (the provider guarantees the two most recently returned
* pages are safe when the cache has >= 2 slots). */
bankN_lo = provider(provider_ctx, cur_bank * 2);
bankN_hi = provider(provider_ctx, cur_bank * 2 + 1);
void Cartridge::fetch_quarter(uint q) const {
/* Refresh the LRU stamps of the quarters already mapped so this
* fetch can never evict them (the provider protects the four most
* recently returned units; at most 3 are mapped when we get here). */
for(uint i = 0; i < 4; i++) {
if(bankN_q[i]) bankN_q[i] = provider(provider_ctx, cur_bank * 4 + i);
}
bankN_q[q] = provider(provider_ctx, cur_bank * 4 + q);
}
void Cartridge::write(u16 addr, u8 value) {
@@ -82,6 +92,7 @@ void Cartridge::write(u16 addr, u8 value) {
case MBCType::None:
return;
case MBCType::MBC1M:
case MBCType::MBC1:
if(addr < 0x2000) {
ram_enabled = (value & 0x0F) == 0x0A;
@@ -127,7 +138,12 @@ void Cartridge::write(u16 addr, u8 value) {
* reads return 0xFF via ram_bank marker) */
ram_bank = value;
} else {
/* RTC latch: unsupported */
/* RTC latch: writing 0x00 then 0x01 latches the clock */
if(rtc_present) {
if(value == 0x00) rtc.latch_armed = true;
else if(value == 0x01 && rtc.latch_armed) rtc_latch();
if(value != 0x00) rtc.latch_armed = false;
}
}
return;
@@ -158,26 +174,80 @@ auto Cartridge::read_ram(u16 addr) const -> u8 {
return static_cast<u8>(ram[(addr - 0xA000) & 0x1FF] | 0xF0);
}
if(mbc == MBCType::MBC3 && ram_bank > 0x03) return 0xFF; /* RTC regs */
if(mbc == MBCType::MBC3 && ram_bank > 0x03) {
if(rtc_present && ram_bank >= 0x08 && ram_bank <= 0x0C)
return rtc_read((u8)(ram_bank - 0x08));
return 0xFF;
}
u32 idx = static_cast<u32>(addr - 0xA000) + static_cast<u32>(ram_bank & 0x0F) * 0x2000;
if(idx >= ram_size) idx %= ram_size;
return ram[idx];
}
auto Cartridge::rtc_seconds() const -> u32 {
if(rtc.halted || !rtc_now) return rtc.halt_value;
return rtc_now() - rtc.base;
}
void Cartridge::rtc_latch() {
u32 t = rtc_seconds();
rtc.latched[0] = (u8)(t % 60);
rtc.latched[1] = (u8)((t / 60) % 60);
rtc.latched[2] = (u8)((t / 3600) % 24);
u32 days = t / 86400;
rtc.latched[3] = (u8)(days & 0xFF);
rtc.latched[4] = (u8)(((days >> 8) & 1) | (rtc.halted ? 0x40 : 0) |
(days > 511 ? 0x80 : 0));
}
auto Cartridge::rtc_read(u8 reg) const -> u8 {
static const u8 mask[5] = {0x3F, 0x3F, 0x1F, 0xFF, 0xC1};
return (u8)(rtc.latched[reg] | (u8)~mask[reg]); /* unused bits read 1 */
}
void Cartridge::rtc_write(u8 reg, u8 value) {
/* rebuild the counter from the current live values with one field
* replaced, then re-derive base */
u32 t = rtc_seconds();
u32 s = t % 60, m = (t / 60) % 60, h = (t / 3600) % 24, d = t / 86400;
switch(reg) {
case 0: s = value % 60; break;
case 1: m = value % 60; break;
case 2: h = value % 24; break;
case 3: d = (d & 0x100) | value; break;
case 4:
d = (d & 0xFF) | ((value & 1) << 8);
rtc.halted = (value & 0x40) != 0;
break;
}
u32 nt = ((d * 24 + h) * 60 + m) * 60 + s;
if(rtc.halted) {
rtc.halt_value = nt;
} else if(rtc_now) {
rtc.base = rtc_now() - nt;
}
}
void Cartridge::write_ram(u16 addr, u8 value) {
if(!ram || !ram_enabled) return;
if(mbc == MBCType::MBC2) {
ram[(addr - 0xA000) & 0x1FF] = value & 0x0F;
ram_written = true;
return;
}
if(mbc == MBCType::MBC3 && ram_bank > 0x03) return; /* RTC regs */
if(mbc == MBCType::MBC3 && ram_bank > 0x03) {
if(rtc_present && ram_bank >= 0x08 && ram_bank <= 0x0C)
rtc_write((u8)(ram_bank - 0x08), value);
return;
}
u32 idx = static_cast<u32>(addr - 0xA000) + static_cast<u32>(ram_bank & 0x0F) * 0x2000;
if(idx >= ram_size) idx %= ram_size;
ram[idx] = value;
ram_written = true;
}
auto Cartridge::parse_mbc(u8 t) -> MBCType {
@@ -18,17 +18,33 @@
* MBC2 (built-in 512x4 RAM), MBC3 (no RTC), MBC5.
*/
using RomBankProvider = const u8* (*)(void* ctx, uint page);
/* The provider hands out 4 KB ROM units (unit n = ROM offset n * 0x1000)
* and guarantees the FOUR most recently returned units stay valid. */
using RomBankProvider = const u8* (*)(void* ctx, uint unit);
enum class MBCType : u8 {
None,
MBC1,
MBC1M, /* MBC1 multicart wiring (bank_high shifts by 4, not 5) */
MBC2,
MBC3,
MBC5,
Unsupported,
};
/* Wall-clock hook for the MBC3 RTC: returns seconds since an arbitrary
* epoch. Host build: time(). Flipper: furi_hal_rtc timestamp. */
using RtcNowProvider = u32 (*)(void);
struct Mbc3Rtc {
/* live counters derived from now() - base, plus the latched copy */
u32 base = 0; /* now() when the RTC was at 0s (adjusted on writes) */
bool halted = false;
u32 halt_value = 0; /* frozen seconds while halted */
u8 latched[5] = {}; /* S, M, H, DL, DH */
bool latch_armed = false;
};
class Cartridge {
public:
/* bank0 must stay valid for the lifetime of the cartridge */
@@ -43,14 +59,16 @@ public:
auto read(u16 addr) const -> u8 {
if(addr < 0x4000) return bank0[addr];
if(addr < 0x6000) return bankN_lo[addr - 0x4000];
if(addr < 0x8000) {
/* lazy: the hi half of a bank is only streamed in when the
* game actually reads 0x6000-0x7FFF from it. Many switches
* exist just to read a table at 0x4xxx; fetching both 8 KB
* pages eagerly doubled the SD misses of streamed games. */
if(!bankN_hi) fetch_hi_page();
return bankN_hi[addr - 0x6000];
/* The switchable bank is four LAZILY mapped 4 KB quarters:
* nothing is fetched at bank-switch time. Measured on Pokemon
* Red: the game rewrites the bank register ~200x per frame
* (trampolines/ISR save-restore) while actually READING from
* only ~17 units -- eager mapping caused ~86 phantom cache
* misses per frame (the whole 100 ms lag). */
uint q = (uint)(addr - 0x4000) >> 12;
if(!bankN_q[q]) fetch_quarter(q);
return bankN_q[q][addr & 0x0FFF];
}
/* 0xA000 - 0xBFFF: cartridge RAM */
return read_ram(addr);
@@ -61,6 +79,38 @@ public:
auto get_ram() -> u8* { return ram; }
auto get_ram_size() const -> u32 { return ram_size; }
/* battery-RAM dirty tracking (frontend autosave) */
auto ram_dirty() const -> bool { return ram_written; }
void clear_ram_dirty() { ram_written = false; }
/* MBC3 RTC */
void set_rtc_provider(RtcNowProvider p) { rtc_now = p; }
auto has_rtc() const -> bool { return rtc_present; }
void set_rtc_present(bool p) { rtc_present = p; }
auto rtc_state() -> Mbc3Rtc& { return rtc; }
/* save-state accessors */
struct BankState {
u8 bank_low, bank_high, ram_bank;
bool ram_enabled, advanced_mode;
};
void export_banks(BankState* st) const {
st->bank_low = (u8)bank_low;
st->bank_high = (u8)bank_high;
st->ram_bank = (u8)ram_bank;
st->ram_enabled = ram_enabled;
st->advanced_mode = advanced_banking_mode;
}
void import_banks(const BankState* st) {
bank_low = st->bank_low;
bank_high = st->bank_high;
ram_bank = st->ram_bank;
ram_enabled = st->ram_enabled;
advanced_banking_mode = st->advanced_mode;
cur_bank = 0xFFFFFFFFu; /* force remap */
update_rom_bank();
}
/* Header helpers (operate on the first bank) */
static auto parse_mbc(u8 cartridge_type_byte) -> MBCType;
static auto has_battery(u8 cartridge_type_byte) -> bool;
@@ -70,13 +120,16 @@ public:
private:
auto read_ram(u16 addr) const -> u8;
void write_ram(u16 addr, u8 value);
void fetch_quarter(uint q) const;
auto rtc_read(u8 reg) const -> u8;
void rtc_write(u8 reg, u8 value);
void rtc_latch();
auto rtc_seconds() const -> u32;
void update_rom_bank();
void fetch_hi_page() const;
const u8* bank0 = nullptr;
mutable const u8* bankN_lo = nullptr; /* 0x4000 - 0x5FFF */
mutable const u8* bankN_hi = nullptr; /* 0x6000 - 0x7FFF, lazy (see read) */
uint cur_bank = 0xFFFFFFFFu; /* currently mapped bank (memo) */
mutable const u8* bankN_q[4] = {}; /* lazy 4 KB quarters of the bank */
uint cur_bank = 0xFFFFFFFFu; /* currently selected bank (memo) */
u8* ram = nullptr;
u32 ram_size = 0;
@@ -88,6 +141,10 @@ private:
uint bank_count = 2;
bool ram_enabled = false;
bool ram_written = false;
bool rtc_present = false;
RtcNowProvider rtc_now = nullptr;
Mbc3Rtc rtc;
bool advanced_banking_mode = false; /* MBC1 mode 1 */
uint bank_low = 1; /* MBC1: 5 bits, MBC3: 7 bits, MBC5: 8 bits */
uint bank_high = 0; /* MBC1: 2 bits, MBC5: 9th bit */
+27 -20
View File
@@ -39,7 +39,11 @@ auto CPU::tick() -> Cycles {
* mask, IF's always-set upper bits (post-boot 0xE1) against a game
* writing IE=0xFF would push PC without dispatching any vector. */
u8 fired = (u8)(interrupt_flag.value() & interrupt_enabled.value() & 0x1F);
if(fired) handle_interrupts(fired);
if(fired) {
/* an actual dispatch costs 5 M-cycles on hardware; the vector's
* first instruction executes on the next tick */
if(handle_interrupts(fired)) return 5;
}
/* Halted: batch 8 M-cycles per iteration. Games spend most of every
* frame in HALT waiting for vblank; stepping 1 cycle at a time made
@@ -58,6 +62,12 @@ auto CPU::tick() -> Cycles {
u16 opcode_pc = pc.value();
auto opcode = get_byte_from_pc();
if(halt_bug) {
/* DMG halt bug: the byte after HALT is fetched without the PC
* advancing, so it is executed twice (or becomes its own operand) */
pc.set(opcode_pc);
halt_bug = false;
}
auto cycles = execute_opcode(opcode, opcode_pc);
if(ei_was_pending && ime_pending) {
@@ -79,34 +89,23 @@ auto CPU::execute_opcode(const u8 opcode, u16 opcode_pc) -> Cycles {
return execute_normal_opcode(opcode, opcode_pc);
}
void CPU::handle_interrupts(u8 fired_interrupts) {
auto CPU::handle_interrupts(u8 fired_interrupts) -> bool {
if (halted) {
// TODO: Handle halt bug
halted = false;
}
if (!interrupts_enabled) {
return;
return false;
}
stack_push(pc);
bool handled_interrupt = false;
handled_interrupt = handle_interrupt(0, interrupts::vblank, fired_interrupts);
if (handled_interrupt) { return; }
handled_interrupt = handle_interrupt(1, interrupts::lcdc_status, fired_interrupts);
if (handled_interrupt) { return; }
handled_interrupt = handle_interrupt(2, interrupts::timer, fired_interrupts);
if (handled_interrupt) { return; }
handled_interrupt = handle_interrupt(3, interrupts::serial, fired_interrupts);
if (handled_interrupt) { return; }
handled_interrupt = handle_interrupt(4, interrupts::joypad, fired_interrupts);
if (handled_interrupt) { return; }
if (handle_interrupt(0, interrupts::vblank, fired_interrupts)) return true;
if (handle_interrupt(1, interrupts::lcdc_status, fired_interrupts)) return true;
if (handle_interrupt(2, interrupts::timer, fired_interrupts)) return true;
if (handle_interrupt(3, interrupts::serial, fired_interrupts)) return true;
if (handle_interrupt(4, interrupts::joypad, fired_interrupts)) return true;
return false; /* unreachable: fired is masked non-zero */
}
auto CPU::handle_interrupt(u8 interrupt_bit, u16 interrupt_vector, u8 fired_interrupts) -> bool {
@@ -117,6 +116,14 @@ auto CPU::handle_interrupt(u8 interrupt_bit, u16 interrupt_vector, u8 fired_inte
interrupt_flag.set_bit_to(interrupt_bit, false);
pc.set(interrupt_vector);
interrupts_enabled = false;
/* belt and braces: a dispatch must never carry the halt-bug PC-repeat
* into the handler's first instruction */
halt_bug = false;
/* A pending EI (redundant EI executed right before the dispatch) must
* die here: without this, IME was re-enabled one instruction INTO the
* interrupt handler, allowing unintended nested interrupts to corrupt
* non-reentrant handlers -- games wedged after a few button presses. */
ime_pending = false;
return true;
}
+33 -1
View File
@@ -51,8 +51,39 @@ public:
ByteRegister interrupt_flag;
ByteRegister interrupt_enabled;
/* save-state support */
struct State {
u16 af, bc, de, hl, sp, pc;
u8 ime, ime_pend, halt, stop;
};
void export_state(State* s) const {
s->af = af.value();
s->bc = bc.value();
s->de = de.value();
s->hl = hl.value();
s->sp = sp.value();
s->pc = pc.value();
s->ime = interrupts_enabled;
s->ime_pend = ime_pending;
s->halt = halted;
s->stop = stopped;
}
void import_state(const State* s) {
af.set(s->af);
bc.set(s->bc);
de.set(s->de);
hl.set(s->hl);
sp.set(s->sp);
pc.set(s->pc);
interrupts_enabled = s->ime != 0;
ime_pending = s->ime_pend != 0;
halted = s->halt != 0;
stopped = s->stop != 0;
halt_bug = false;
}
private:
void handle_interrupts(u8 fired_interrupts);
auto handle_interrupts(u8 fired_interrupts) -> bool; /* true = dispatched */
auto handle_interrupt(u8 interrupt_bit, u16 interrupt_vector, u8 fired_interrupts) -> bool;
Gameboy& gb;
@@ -61,6 +92,7 @@ private:
bool ime_pending = false; /* EI takes effect AFTER the next instruction */
bool halted = false;
bool stopped = false; /* STOP: woken only by joypad activity */
bool halt_bug = false; /* HALT w/ IME=0 and pending IF&IE: PC repeats */
public:
/* Joypad line activity: wakes STOP unconditionally (real DMG behaviour:
@@ -7,6 +7,7 @@ using uint = unsigned int;
using u8 = uint8_t;
using u16 = uint16_t;
using u32 = uint32_t;
using u64 = uint64_t;
using s8 = int8_t;
using s16 = uint16_t; /* kept as upstream (unused alias) */
@@ -43,15 +43,16 @@ void Gameboy::vblank_trampoline(void* ctx) {
void Gameboy::button_pressed(GbButton button) {
input.button_pressed(button);
/* Request the joypad interrupt (missing upstream); mainly wakes
* games waiting in HALT for input. */
cpu.interrupt_flag.set_bit_to(4, true);
/* STOP wakes on joypad activity regardless of IE/IF/IME */
/* Joypad interrupt: hardware only triggers for currently selected
* P1 matrix lines. Mainly wakes games waiting in HALT for input. */
if(input.line_selected(button)) cpu.interrupt_flag.set_bit_to(4, true);
/* STOP wakes on any joypad line activity regardless of IE/IF/IME */
cpu.notify_joypad();
}
void Gameboy::button_released(GbButton button) {
input.button_released(button);
cpu.notify_joypad(); /* STOP wakes on any line CHANGE, release included */
}
void Gameboy::run_to_vblank() {
@@ -46,6 +46,7 @@ public:
video.tick(Cycles(n));
timer.tick(n);
apu.tick(n);
mmu.serial_tick(n);
}
}
@@ -155,14 +156,12 @@ inline void Timer::tick(uint cycles) {
div_clocks &= 63;
}
/* Accumulate T-cycles only while the timer is enabled: accumulating
* with TAC off built up an unbounded backlog that, when a game later
* enabled the timer, drained as a burst of TIMA overflows + spurious
* timer interrupts (plus a long stall in the loop below). */
if(!timer_control.check_bit(2)) {
clocks = 0;
return;
}
/* Accumulate T-cycles only while the timer is enabled (accumulating
* with TAC off built an unbounded backlog that drained as a burst of
* spurious overflows when the timer was enabled). The sub-period
* remainder is kept across TAC toggles, matching hardware phase
* behaviour more closely. */
if(!timer_control.check_bit(2)) return;
clocks += cycles * 4; /* M-cycles -> T-cycles */
uint clock_limit = clocks_needed_to_increment();
@@ -181,6 +180,19 @@ inline void Timer::tick(uint cycles) {
}
}
inline void MMU::serial_tick(uint cycles) {
if(!serial_clocks) return;
if(serial_clocks > cycles) {
serial_clocks -= cycles;
return;
}
/* transfer complete: no link partner, the line reads all 1s */
serial_clocks = 0;
serial_data = 0xFF;
serial_control &= 0x7F; /* clear the busy bit */
gb.cpu.interrupt_flag.set_bit_to(3, true);
}
inline auto CPU::get_byte_from_pc() -> u8 {
u16 a = pc.value();
pc.increment();
@@ -1,3 +1,10 @@
/* Cold code: size-optimized on the embedded target. Every KB of binary
* is a KB of heap the ROM page cache loses (the FAP loads into RAM), and
* nothing in this file runs per-instruction. */
#if defined(__arm__) && defined(__GNUC__)
#pragma GCC optimize("Os")
#endif
#include "input.h"
#include "bitwise.h"
@@ -31,22 +38,33 @@ void Input::write(u8 set) {
auto Input::get_input() const -> u8 {
using bitwise::set_bit_to;
u8 buttons = 0b1111;
/* with both matrix lines selected the nibbles combine (AND: pressed
* pulls low); the old code let the button nibble overwrite the
* direction nibble */
u8 dir = 0b1111, btn = 0b1111;
if (direction_switch) {
buttons = set_bit_to(buttons, 0, !right);
buttons = set_bit_to(buttons, 1, !left);
buttons = set_bit_to(buttons, 2, !up);
buttons = set_bit_to(buttons, 3, !down);
/* opposite directions are mechanically impossible on the real
* d-pad rocker: if a frontend ever feeds both, show neither
* (games misbehave badly on impossible pad states) */
bool r = right, l = left, u = up, d = down;
if(r && l) r = l = false;
if(u && d) u = d = false;
dir = set_bit_to(dir, 0, !r);
dir = set_bit_to(dir, 1, !l);
dir = set_bit_to(dir, 2, !u);
dir = set_bit_to(dir, 3, !d);
}
if (button_switch) {
buttons = set_bit_to(buttons, 0, !a);
buttons = set_bit_to(buttons, 1, !b);
buttons = set_bit_to(buttons, 2, !select);
buttons = set_bit_to(buttons, 3, !start);
btn = set_bit_to(btn, 0, !a);
btn = set_bit_to(btn, 1, !b);
btn = set_bit_to(btn, 2, !select);
btn = set_bit_to(btn, 3, !start);
}
u8 buttons = static_cast<u8>(dir & btn);
buttons = set_bit_to(buttons, 4, !direction_switch);
buttons = set_bit_to(buttons, 5, !button_switch);
@@ -21,6 +21,14 @@ public:
auto get_input() const -> u8;
/* is this button's matrix line currently selected via P1? (the joypad
* interrupt only triggers for selected lines on hardware) */
auto line_selected(GbButton b) const -> bool {
bool is_dir = (b == GbButton::Up || b == GbButton::Down ||
b == GbButton::Left || b == GbButton::Right);
return is_dir ? direction_switch : button_switch;
}
private:
void set_button(GbButton button, bool set);
+24 -10
View File
@@ -110,7 +110,10 @@ auto MMU::read_io(const Address& address) const -> u8 {
return serial_data;
case 0xFF02:
return 0xFF;
/* bit 7 reads 1 while a transfer is in flight; only bits 7 and 0
* are wired on DMG. The old hardwired 0xFF made SC look "busy
* forever" to games polling for completion. */
return static_cast<u8>(serial_control | 0x7E);
case 0xFF04:
return gb.timer.get_divider();
@@ -125,13 +128,14 @@ auto MMU::read_io(const Address& address) const -> u8 {
return gb.timer.get_timer_control();
case 0xFF0F:
return gb.cpu.interrupt_flag.value();
/* bits 5-7 are unwired and read as 1 */
return static_cast<u8>(gb.cpu.interrupt_flag.value() | 0xE0);
case 0xFF40:
return gb.video.control_byte;
case 0xFF41:
return gb.video.lcd_status.value();
return static_cast<u8>(gb.video.lcd_status.value() | 0x80); /* bit 7 unwired */
case 0xFF42:
return gb.video.scroll_y.value();
@@ -140,7 +144,7 @@ auto MMU::read_io(const Address& address) const -> u8 {
return gb.video.scroll_x.value();
case 0xFF44:
return gb.video.line.value();
return gb.video.ly_read(); /* line-153 quirk: reads 0 */
case 0xFF45:
return gb.video.ly_compare.value();
@@ -191,10 +195,18 @@ void MMU::write_io(const Address& address, u8 byte) {
return;
case 0xFF02:
/* Serial control: transfer start with internal clock -> deliver
* the byte immediately (enough for link-less games and for the
* Blargg test ROMs which print through the serial port) */
if((byte & 0x81) == 0x81 && gb_serial_hook) gb_serial_hook(serial_data);
/* Serial control. A master transfer (internal clock) with no
* cable attached still completes on real hardware: 0xFF shifts
* in after 8 bit-clocks (4096 T-cycles = 1024 M-cycles at
* 8192 Hz) and the serial interrupt fires. Tetris's 2P handshake
* (Start at the title with 2 PLAYER selected) busy-waits on that
* interrupt: without it the game wedged forever. A slave start
* (0x80) never completes without a partner clock - correct. */
serial_control = byte & 0x81;
if((byte & 0x81) == 0x81) {
if(gb_serial_hook) gb_serial_hook(serial_data);
serial_clocks = 1024;
}
return;
case 0xFF04:
@@ -222,7 +234,9 @@ void MMU::write_io(const Address& address, u8 byte) {
return;
case 0xFF41:
gb.video.lcd_status.set(byte);
/* bits 0-2 (mode + coincidence) are read-only */
gb.video.lcd_status.set(
static_cast<u8>((byte & 0x78) | (gb.video.lcd_status.value() & 0x07)));
return;
case 0xFF42:
@@ -234,7 +248,7 @@ void MMU::write_io(const Address& address, u8 byte) {
return;
case 0xFF44:
gb.video.line.set(0x0);
/* LY is read-only on DMG (upstream reset it to 0) */
return;
case 0xFF45:
@@ -18,6 +18,28 @@ public:
auto read(const Address& address) const -> u8;
void write(const Address& address, u8 byte);
/* Serial link: a master transfer (SC=0x81) with no cable completes
* after 8 bit-clocks reading 0xFF and raises the serial interrupt.
* Without this, games that probe the link on demand (Tetris 2P
* handshake at the title screen!) busy-wait forever. Ticked from
* Gameboy::sync_peripherals; defined inline in gameboy.h. */
void serial_tick(uint cycles);
/* save-state accessors */
auto wram_ptr() -> u8* { return work_ram; }
auto oam_ptr() -> u8* { return oam_ram; }
auto hram_ptr() -> u8* { return high_ram; }
void export_serial(u8* sb, u8* sc, u32* clk) const {
*sb = serial_data;
*sc = serial_control;
*clk = serial_clocks;
}
void import_serial(u8 sb, u8 sc, u32 clk) {
serial_data = sb;
serial_control = sc;
serial_clocks = clk;
}
private:
auto read_io(const Address& address) const -> u8;
void write_io(const Address& address, u8 byte);
@@ -30,6 +52,8 @@ private:
u8 high_ram[0x80] = {};
u8 serial_data = 0;
u8 serial_control = 0;
uint serial_clocks = 0; /* M-cycles until the running transfer completes */
friend class Video;
};
@@ -342,9 +342,25 @@ void CPU::opcode_jr(Condition condition) {
}
/* HALT */
/* HALT. DMG halt bug: entering HALT with IME=0 while an enabled
* interrupt is already pending does NOT halt -- instead the byte after
* HALT is executed twice (PC fails to advance once).
*
* CRITICAL: a pending EI (the `EI; HALT` idiom -- HALT executes inside
* EI's delay slot with IME still momentarily 0) is NOT the halt-bug
* case: IME rises right after HALT and the interrupt dispatches
* normally. Without the ime_pending check, the stale halt_bug flag
* survived into the dispatch and corrupted the ISR's first instruction
* fetch (PC reset made the operand re-read the opcode byte = wild
* jump): Mario wedged "walking right forever, music playing" after a
* few A presses. */
void CPU::opcode_halt() {
halted = true;
if(!interrupts_enabled && !ime_pending &&
(interrupt_flag.value() & interrupt_enabled.value() & 0x1F)) {
halt_bug = true;
} else {
halted = true;
}
}
@@ -20,7 +20,17 @@ public:
/* Only the bottom three bits of this register are usable */
auto get_timer_control() const -> u8 { return timer_control.value() & 0x7; }
void reset_divider() { divider.set(0x0); }
void reset_divider() {
/* DIV writes reset the shared prescaler: TIMA phase resets too */
divider.set(0x0);
div_clocks = 0;
clocks = 0;
}
/* save-state accessors */
void set_counters(uint c, uint d) { clocks = c; div_clocks = d; }
auto get_clocks() const -> uint { return clocks; }
auto get_div_clocks() const -> uint { return div_clocks; }
void set_divider_raw(u8 v) { divider.set(v); }
void set_timer(u8 value) { timer_counter.set(value); }
void set_timer_modulo(u8 value) { timer_modulo.set(value); }
void set_timer_control(u8 value) { timer_control.set(value); }
+117 -103
View File
@@ -11,23 +11,27 @@ using bitwise::check_bit;
/* s_tile_lut[b] spreads the 8 bits of a tile-data byte into 8 2-bit pixel
* fields, leftmost pixel (bit 7) in the LOWEST field -- the same LSB-first
* packing the framebuffer uses, so whole bytes can be emitted directly.
* s_tile_lut_rev is the X-flipped variant for sprites. 1 KB total. */
* X-flipped sprites bit-reverse the tile bytes first (cheaper than a
* second 512-byte LUT: this binary is RAM on the Flipper). */
static u16 s_tile_lut[256];
static u16 s_tile_lut_rev[256];
static bool s_luts_ready = false;
static inline u8 bitrev8(u8 b) {
b = (u8)((b >> 4) | (b << 4));
b = (u8)(((b & 0xCC) >> 2) | ((b & 0x33) << 2));
b = (u8)(((b & 0xAA) >> 1) | ((b & 0x55) << 1));
return b;
}
Video::Video(Gameboy& inGb)
: gb(inGb) {
if(!s_luts_ready) {
for(uint b = 0; b < 256; b++) {
u16 fwd = 0, rev = 0;
u16 fwd = 0;
for(uint k = 0; k < 8; k++) {
u16 bit = (u16)((b >> (7 - k)) & 1);
fwd |= (u16)(bit << (2 * k));
rev |= (u16)(bit << (2 * (7 - k)));
fwd |= (u16)(((b >> (7 - k)) & 1) << (2 * k));
}
s_tile_lut[b] = fwd;
s_tile_lut_rev[b] = rev;
}
s_luts_ready = true;
}
@@ -35,7 +39,7 @@ Video::Video(Gameboy& inGb)
/* Palette LUT: maps a packed 2bpp byte (4 pixels) through BGP in one
* lookup. Rebuilt only when the game writes a new BGP value. */
auto Video::bg_pal_lut() -> const u8* {
__attribute__((optimize("Os"))) auto Video::bg_pal_lut() -> const u8* {
u8 bgp = bg_palette.value();
if(!bgp_lut_valid || bgp != bgp_lut_cached_for) {
u8 shade[4] = {
@@ -59,29 +63,33 @@ auto Video::bg_pal_lut() -> const u8* {
* inline definition at the bottom of gameboy.h. Mode-transition work
* (rendering, interrupts) stays out-of-line in this file. */
void Video::mode_transition_vram_end() {
__attribute__((optimize("Os"))) void Video::mode_transition_vram_end() {
current_mode = VideoMode::HBLANK;
bool hblank_interrupt = check_bit(lcd_status.value(), 3);
if(hblank_interrupt) {
gb.cpu.interrupt_flag.set_bit_to(1, true);
if(check_bit(lcd_status.value(), 3)) {
gb.cpu.interrupt_flag.set_bit_to(1, true); /* hblank STAT */
}
bool ly_coincidence_interrupt = check_bit(lcd_status.value(), 6);
bool ly_coincidence = ly_compare.value() == line.value();
if(ly_coincidence_interrupt && ly_coincidence) {
gb.cpu.interrupt_flag.set_bit_to(1, true);
}
lcd_status.set_bit_to(2, ly_coincidence);
lcd_status.set_bit_to(1, false);
lcd_status.set_bit_to(0, false);
}
void Video::mode_transition_hblank_end() {
/* LY=LYC is evaluated when a line BEGINS (hardware: during mode 2 / OAM
* scan). Firing it at the end of the line -- as upstream did -- made
* games' raster effects (dmg-acid2 uses LYC IRQs to change WX/LCDC on
* exact rows) apply one line late. */
__attribute__((optimize("Os"))) void Video::check_lyc() {
bool eq = ly_compare.value() == line.value();
if(eq && check_bit(lcd_status.value(), 6)) {
gb.cpu.interrupt_flag.set_bit_to(1, true);
}
lcd_status.set_bit_to(2, eq);
}
__attribute__((optimize("Os"))) void Video::mode_transition_hblank_end() {
if(!skip_render) write_scanline(line.value());
line.increment();
check_lyc(); /* new line starts here */
/* Line 145 (index 144) is the first line of VBLANK */
if(line == 144) {
@@ -96,27 +104,24 @@ void Video::mode_transition_hblank_end() {
}
}
void Video::mode_transition_vblank_line() {
__attribute__((optimize("Os"))) void Video::mode_transition_vblank_line() {
line.increment();
/* LY=LYC STAT interrupt must also fire for lines 144-153: games that
* wait for a coincidence inside vblank hung without this (it was only
* evaluated at the mode3->hblank transition of visible lines) */
if(check_bit(lcd_status.value(), 6) && ly_compare.value() == line.value()) {
gb.cpu.interrupt_flag.set_bit_to(1, true);
}
lcd_status.set_bit_to(2, ly_compare.value() == line.value());
/* LY=LYC also fires for vblank lines 145-153 (games wait on it).
* Line 154 is transient (LY wraps to 0) and never matches. */
if(line.value() < 154) check_lyc();
/* Line 155 (index 154) is the last line */
if(line == 154) {
if(!skip_render) {
write_sprites();
draw();
buffer.reset();
} else {
draw(); /* still notify the frontend for pacing */
}
line.reset();
window_line = 0; /* the window's internal line counter is per-frame */
check_lyc(); /* line 0 begins */
current_mode = VideoMode::ACCESS_OAM;
lcd_status.set_bit_to(1, true);
lcd_status.set_bit_to(0, false);
@@ -165,15 +170,12 @@ void Video::write_scanline(u8 current_line) {
if(window_enabled()) {
draw_window_line(current_line);
}
}
void Video::write_sprites() {
if(!sprites_enabled()) {
return;
}
for(uint sprite_n = 0; sprite_n < 40; sprite_n++) {
draw_sprite(sprite_n);
/* Sprites are composited per scanline like hardware: mid-frame raster
* changes to OBP/LCDC (object size/enable) apply to the correct rows
* (dmg-acid2 exercises this), and only displayed lines pay the cost. */
if(sprites_enabled()) {
draw_sprites_line(current_line);
}
}
@@ -284,23 +286,27 @@ void Video::draw_window_line(uint current_line) {
bool use_tile_set_zero = bg_window_tile_data();
bool use_tile_map_zero = !window_tile_map();
uint scrolled_y = current_line - window_y.value();
if(scrolled_y >= GAMEBOY_HEIGHT) {
return;
}
/* the window only starts once LY reaches WY */
if(current_line < window_y.value()) return;
/* the window covers screen pixels from WX-7 onward, sourcing the
* window map from column 0 */
/* WX off-screen hides the window for this line WITHOUT advancing its
* internal line counter: when it reappears, drawing resumes from the
* next window row, not from LY-WY (dmg-acid2 "chin" behaviour) */
uint wx = window_x.value();
if(wx > 166) return;
uint start_x = wx >= 7 ? wx - 7 : 0;
if(start_x >= GAMEBOY_WIDTH) return;
uint src_row = window_line; /* internal per-frame counter */
if(src_row >= 256) return;
window_line++; /* the counter advances on every line the window shows */
u8* dst = buffer.row_ptr(current_line);
if(!dst) return;
if(!dst) return; /* row not displayed: counter still advanced above */
uint map_row_base = (use_tile_map_zero ? (TILE_MAP_ZERO_ADDRESS - 0x8000) :
(TILE_MAP_ONE_ADDRESS - 0x8000)) +
(scrolled_y / TILE_HEIGHT_PX) * TILES_PER_LINE;
((src_row / TILE_HEIGHT_PX) & 31) * TILES_PER_LINE;
render_strip(
dst,
@@ -308,85 +314,93 @@ void Video::draw_window_line(uint current_line) {
GAMEBOY_WIDTH - start_x,
map_row_base,
wx >= 7 ? 0 : 7 - wx,
(scrolled_y % TILE_HEIGHT_PX) * 2,
(src_row % TILE_HEIGHT_PX) * 2,
use_tile_set_zero,
bg_pal_lut());
}
void Video::draw_sprite(const uint sprite_n) {
/* Each sprite is represented by 4 bytes */
u16 oam_start = static_cast<u16>(sprite_n * SPRITE_BYTES);
u8 sprite_y = gb.mmu.oam_ram[oam_start];
u8 sprite_x = gb.mmu.oam_ram[oam_start + 1];
/* Offscreen sprites are not drawn */
if(sprite_y == 0 || sprite_y >= 160) {
return;
}
if(sprite_x == 0 || sprite_x >= 168) {
return;
}
void Video::draw_sprites_line(uint current_line) {
uint sprite_height = sprite_size() ? 16 : 8;
u8 pattern_n = gb.mmu.oam_ram[oam_start + 2];
u8 sprite_attrs = gb.mmu.oam_ram[oam_start + 3];
/* hardware OAM scan: the first 10 sprites (in OAM order) covering
* this line are selected */
u8 sel[10];
uint nsel = 0;
for(uint n = 0; n < 40 && nsel < 10; n++) {
int sy = (int)gb.mmu.oam_ram[n * 4] - 16;
if((int)current_line >= sy && (int)current_line < sy + (int)sprite_height) {
sel[nsel++] = (u8)n;
}
}
if(nsel == 0) return;
/* Bits 0-3 are used only for CGB */
bool use_palette_1 = check_bit(sprite_attrs, 4);
bool flip_x = check_bit(sprite_attrs, 5);
bool flip_y = check_bit(sprite_attrs, 6);
bool obj_behind_bg = check_bit(sprite_attrs, 7);
u8* row = buffer.row_ptr(current_line);
if(!row) return;
/* 4-entry shade table instead of a per-pixel palette switch */
u8 obp = use_palette_1 ? sprite_palette_1.value() : sprite_palette_0.value();
u8 shade[4] = {
(u8)(obp & 0x3),
(u8)((obp >> 2) & 0x3),
(u8)((obp >> 4) & 0x3),
(u8)((obp >> 6) & 0x3),
};
/* draw in reverse priority order so the winner is painted last:
* lower X wins overlaps, ties broken by lower OAM index */
for(uint i = 1; i < nsel; i++) { /* insertion sort by X descending */
u8 v = sel[i];
u8 vx = gb.mmu.oam_ram[v * 4 + 1];
uint j = i;
while(j > 0 && gb.mmu.oam_ram[sel[j - 1] * 4 + 1] < vx) {
sel[j] = sel[j - 1];
j--;
}
sel[j] = v;
}
uint tile_offset = pattern_n * TILE_BYTES;
for(uint i = 0; i < nsel; i++) {
uint n = sel[i];
u16 oam = (u16)(n * 4);
u8 sprite_y = gb.mmu.oam_ram[oam];
u8 sprite_x = gb.mmu.oam_ram[oam + 1];
if(sprite_x == 0 || sprite_x >= 168) continue; /* off-screen */
int start_y = sprite_y - 16;
int start_x = sprite_x - 8;
u8 pattern_n = gb.mmu.oam_ram[oam + 2];
u8 attrs = gb.mmu.oam_ram[oam + 3];
bool use_palette_1 = check_bit(attrs, 4);
bool flip_x = check_bit(attrs, 5);
bool flip_y = check_bit(attrs, 6);
bool obj_behind_bg = check_bit(attrs, 7);
const u16* lut = flip_x ? s_tile_lut_rev : s_tile_lut;
/* in 8x16 mode the hardware ignores bit 0 of the tile id */
if(sprite_height == 16) pattern_n &= 0xFE;
for(uint y = 0; y < sprite_height; y++) {
int screen_y = start_y + static_cast<int>(y);
if(screen_y < 0 || screen_y >= static_cast<int>(GAMEBOY_HEIGHT)) continue;
if(row_mask && !row_mask[screen_y]) continue;
u8 obp = use_palette_1 ? sprite_palette_1.value() : sprite_palette_0.value();
u8 shade[4] = {
(u8)(obp & 0x3),
(u8)((obp >> 2) & 0x3),
(u8)((obp >> 4) & 0x3),
(u8)((obp >> 6) & 0x3),
};
u8* row = buffer.row_ptr(static_cast<uint>(screen_y));
if(!row) continue;
uint y = current_line - (uint)(sprite_y - 16);
uint src_y = flip_y ? sprite_height - 1 - y : y;
uint line_addr = pattern_n * TILE_BYTES + src_y * 2;
uint src_y = !flip_y ? y : sprite_height - y - 1;
uint line_addr = tile_offset + src_y * 2; /* relative to tile set zero */
/* all 8 pixel colors of this sprite row, LSB-first left-to-right
* (flip handled by the reversed LUT) */
u16 v = (u16)(lut[video_ram[line_addr]] | (lut[video_ram[line_addr + 1]] << 1));
if(v == 0) continue; /* fully transparent row: common, skip */
u8 b1 = video_ram[line_addr];
u8 b2 = video_ram[line_addr + 1];
if(flip_x) {
b1 = bitrev8(b1);
b2 = bitrev8(b2);
}
u16 v = (u16)(s_tile_lut[b1] | (s_tile_lut[b2] << 1));
if(v == 0) continue; /* fully transparent row */
int start_x = (int)sprite_x - 8;
for(uint x = 0; x < TILE_WIDTH_PX; x++, v >>= 2) {
uint color = v & 3;
if(color == 0) continue; /* color 0 is transparent */
if(color == 0) continue; /* transparent */
int screen_x = start_x + static_cast<int>(x);
if(screen_x < 0 || screen_x >= static_cast<int>(GAMEBOY_WIDTH)) continue;
int screen_x = start_x + (int)x;
if(screen_x < 0 || screen_x >= (int)GAMEBOY_WIDTH) continue;
uint sh = ((uint)screen_x & 3) * 2;
u8* p = row + ((uint)screen_x >> 2);
/* Note: same behaviour as upstream - the priority bit compares
* the final shade rather than the logical color 0 */
if(obj_behind_bg && ((*p >> sh) & 3) != SHADE_WHITE) {
continue;
}
/* same as upstream: priority compares the final shade */
if(obj_behind_bg && ((*p >> sh) & 3) != SHADE_WHITE) continue;
*p = (u8)((*p & ~(3u << sh)) | (shade[color] << sh));
}
@@ -28,6 +28,7 @@ public:
}
auto vram_read(u16 offset) const -> u8 { return video_ram[offset]; }
auto vram_ptr() -> u8* { return video_ram; } /* save states */
void vram_write(u16 offset, u8 value) { video_ram[offset] = value; }
/* When true, scanline/sprite rendering work is skipped (frame skip);
@@ -70,14 +71,14 @@ public:
private:
void write_scanline(u8 current_line);
void write_sprites();
void draw_sprites_line(uint current_line);
void draw();
void mode_transition_vram_end();
void check_lyc();
void mode_transition_hblank_end();
void mode_transition_vblank_line();
void draw_bg_line(uint current_line);
void draw_window_line(uint current_line);
void draw_sprite(uint sprite_n);
void render_strip(
u8* dst_row,
uint dst_x,
@@ -112,6 +113,26 @@ private:
u8 bgp_lut_cached_for = 0;
bool bgp_lut_valid = false;
/* window internal line counter (per frame, advances only on lines
* where the window is actually shown) */
uint window_line = 0;
public:
/* LY as the CPU sees it (line 153 reads 0 for most of its duration) */
auto ly_read() const -> u8 {
u8 v = line.value();
return v == 153 ? 0 : v;
}
/* save-state accessors */
auto get_mode() const -> u8 { return (u8)current_mode; }
auto get_cycle_counter() const -> uint { return cycle_counter; }
void set_state(u8 ly, u8 mode, uint counter) {
line.set(ly);
current_mode = (VideoMode)(mode & 3);
cycle_counter = counter;
}
private:
u8 video_ram[0x2000] = {}; /* DMG: 8 KB (was 16 KB upstream) */
VideoMode current_mode = VideoMode::ACCESS_OAM;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,44 @@
# Contributing to TagTinker
Thanks for wanting to help out! Here's how to get involved.
## Getting Started
1. Fork the repo
2. Create a feature branch: `git checkout -b my-feature`
3. Make your changes
4. Test on hardware if possible (or at minimum, ensure `ufbt build` passes)
5. Commit with a clear message
6. Open a pull request
## Code Style
- C99, no C++ features
- 4-space indentation (no tabs)
- `snake_case` for functions and variables
- Keep functions short and focused
## What We're Looking For
- Bug fixes (especially memory-related — the Flipper has very limited heap)
- Support for new ESL tag sizes and models
- NFC tag support for additional ESL formats
- UI/UX improvements
- Android companion app improvements
## Pull Request Guidelines
- Use **"Create a merge commit"** when merging to preserve contributor attribution.
- Keep commits focused — one feature or fix per PR.
## Reporting Issues
Please open a GitHub issue with:
- What you expected to happen
- What actually happened
- Your Flipper firmware version
- The ESL model you're testing with (if applicable)
## License
By contributing, you agree that your contributions will be licensed under GPL-3.0.
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+87
View File
@@ -0,0 +1,87 @@
# TagTinker V2.1
<p align="center">
<strong>Infrared ESL Research Toolkit for Flipper Zero</strong><br>
<sub>Protocol study • Signal analysis • Digital Art</sub>
</p>
<p align="center">
<img alt="License: GPL-3.0" src="https://img.shields.io/badge/License-GPL--3.0-blue.svg">
<img alt="Platform: Flipper Zero" src="https://img.shields.io/badge/Platform-Flipper%20Zero-black.svg">
<a href="https://i12bp8.github.io/TagTinker/"><img alt="Image Prep" src="https://img.shields.io/badge/Image%20Prep-Open%20in%20browser-a78bfa?logo=github"></a>
</p>
<p align="center">
<strong><a href="https://i12bp8.github.io/TagTinker/">→ Launch the TagTinker Image Prep web app ←</a></strong>
</p>
<img alt="Demo Image" src="https://raw.githubusercontent.com/i12bp8/TagTinker/refs/heads/main/tagtinkerdemo.jpg">
## Overview
TagTinker is a Flipper Zero app for exploring infrared electronic shelf-label (ESL) protocols. It allows you to transmit custom images and text to supported graphics tags. A companion **web image preparer** runs entirely in the browser and lets you drop, dither and download Flipper-ready BMPs without any install.
As the Flipper Zero team notes:
> "FYI: this is pure infrared signal, same that you use in TV remotes. The whole security was relying on obscurity of protocol."
This tool is built for IoT security curiosity, learning about obscure protocols, and displaying digital art on e-ink hardware.
> [!WARNING]
> **Hardware Warning:** Many infrared ESL tags store their firmware, address, and display data in volatile RAM to save cost and energy. If you remove the battery or let it fully discharge, the tag will lose all programming and become unresponsive ("dead"). It usually cannot be recovered without the original base station.
## Features
- **TagTinker Flipper App:** High-performance, zero-allocation RLE streaming IR engine.
- **TagTinker Image Prep (web):** Single-file, dependency-free HTML page that lists every supported tag profile, runs a full image pipeline (tone, contrast, detail, sharpen, dither, photo-grade Oklab 3-colour quantisation) and exports a Flipper-ready BMP. Hosted at **[i12bp8.github.io/TagTinker](https://i12bp8.github.io/TagTinker/)** (source: `web-image-prep/`).
- **Drop-folder image flow:** Drop a prepared BMP into `apps_data/tagtinker/dropped/` on the Flipper SD card, then open `Targeted Payloads → <tag> → Set Image` and pick it. The Flipper rescales any BMP on the fly so a single file can target any tag and any page.
- **NFC Tag Scan:** Instantly identify ESL targets by scanning their NFC tag — no manual barcode entry needed.
- **WiFi Plugins (optional):** Plug a Flipper WiFi Dev Board (ESP32-S2) into the GPIO header to unlock live, network-rendered tag designs — crypto price cards, weather tiles, identicons, and more — auto-discovered by the FAP. New plugins live entirely on the cloud worker; the Flipper firmware never has to be re-flashed to add one.
<img alt="image" src="https://raw.githubusercontent.com/i12bp8/TagTinker/refs/heads/main/PXL_20260427_092219442.jpg" />
- Display text, custom images, and test-patterns.
- Support for monochrome and accent-color (red/yellow) graphics tags.
## Getting Started
1. Build the Flipper app from this repository and install it via `ufbt`. The first launch creates `apps_data/tagtinker/dropped/` on your SD card.
2. Open **[i12bp8.github.io/TagTinker](https://i12bp8.github.io/TagTinker/)** in any browser, pick your tag profile, drop an image, tweak, and download the BMP.
3. Copy the BMP into `apps_data/tagtinker/dropped/` on the SD card (over `qFlipper`, USB MTP, or whatever you use).
4. On the Flipper open `Targeted Payloads → <your tag> → Set Image`, pick the BMP, choose a page, send.
## FAQ
**Does this require a Flipper Zero?**
No, not at all! You can do this with less than $5 worth of microcontroller hardware (like an ESP32 and an IR LED). The Flipper Zero just happens to be my favorite security research tool, which is why I built the app for this platform.
**Where is the `.fap` release?**
The Flipper app is source-first. Build the `.fap` yourself from this repository with `ufbt` so it matches your firmware and local toolchain.
**What if it crashes or behaves oddly?**
If you are using a custom firmware branch, custom asset packs, or a heavily modified device setup, start by testing from a clean baseline firmware.
## Credits & Background
This project is deeply indebted to the incredible public reverse-engineering work by **furrtek**.
To understand the underlying protocol, signal structure, and history, please read his research:
- **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]
> **STRICTLY PROHIBITED FOR ILLEGAL USE**
>
> TagTinker is an independent project intended **strictly** for educational research, security curiosity, and displaying digital art on hardware that **you legally own**.
>
> Under no circumstances is this software allowed to be used for illegal activities. You are strictly prohibited from using TagTinker to alter retail displays, modify electronic shelf labels in stores, interfere with third-party infrastructure, or cause any form of vandalism or financial harm.
>
> The creator of TagTinker assumes absolutely no liability for any misuse of this software. By using this software, you agree to take full responsibility for your actions and use it responsibly and legally.
## License
Licensed under the **GNU General Public License v3.0** (GPL-3.0). See the [`LICENSE`](LICENSE) file for details.
@@ -0,0 +1,43 @@
App(
appid="tagtinker",
name="TagTinker",
apptype=FlipperAppType.EXTERNAL,
entry_point="tagtinker_app_main",
requires=["gui", "notification", "dialogs", "storage", "bt", "nfc", "expansion"],
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.1",
sources=[
"tagtinker_app.c",
"ir/tagtinker_ir.c",
"protocol/tagtinker_proto.c",
"scenes/tagtinker_scene.c",
"scenes/tagtinker_scene_about.c",
"scenes/tagtinker_scene_barcode_input.c",
"scenes/tagtinker_scene_broadcast.c",
"scenes/tagtinker_scene_broadcast_menu.c",
"scenes/tagtinker_scene_image_options.c",
"scenes/tagtinker_scene_main_menu.c",
"scenes/tagtinker_scene_preset_list.c",
"scenes/tagtinker_scene_synced_image_list.c",
"scenes/tagtinker_scene_settings.c",
"scenes/tagtinker_scene_size_picker.c",
"scenes/tagtinker_scene_target_actions.c",
"scenes/tagtinker_scene_target_menu.c",
"scenes/tagtinker_scene_text_input.c",
"scenes/tagtinker_scene_transmit.c",
"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",
"wifi/tagtinker_wifi.c",
"wifi/tagtinker_wifi_bmp.c",
"scenes/tagtinker_scene_wifi_plugins.c",
"scenes/tagtinker_scene_wifi_setup.c",
"scenes/tagtinker_scene_wifi_run.c",
],
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,20 @@
{
"name": "tagtinker-cloud-plugins",
"version": "1.0.0",
"private": true,
"description": "Cloudflare Worker that renders TagTinker WiFi plugins server-side.",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"tail": "wrangler tail"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20240909.0",
"typescript": "^5.5.4",
"wrangler": "^3.78.0"
},
"dependencies": {
"jpeg-js": "^0.4.4",
"upng-js": "^2.1.0"
}
}
@@ -0,0 +1,293 @@
/*
* Server-side 1bpp / 2bpp canvas. Each plane is a packed bitmap with the
* exact byte layout the ESP forwards to the Flipper (and the Flipper
* TXes verbatim to the tag): rows top-down, bytes MSB-first within a
* row, row_stride padded to 4 bytes (BMP convention).
*
* bit == 0 -> ink off (white)
* bit == 1 -> ink on (black on plane 0, accent on plane 1)
*
* Plane 1 only allocated when accent is requested AND supported.
*/
import { FONT_5x7, FONT_5x7_W, FONT_5x7_H, FONT_EXTRA } from "./font";
export type Ink = 0 | 1; // 0 = primary (black), 1 = accent (red/yellow)
export class Canvas {
readonly width: number;
readonly height: number;
readonly planes: number;
readonly rowStride: number;
readonly plane0: Uint8Array;
readonly plane1: Uint8Array | null;
constructor(width: number, height: number, planes: 1 | 2) {
this.width = width;
this.height = height;
this.planes = planes;
this.rowStride = ((width + 31) >> 5) << 2; // round up to 4 bytes
this.plane0 = new Uint8Array(this.rowStride * height);
this.plane1 = planes === 2 ? new Uint8Array(this.rowStride * height) : null;
}
private plane(ink: Ink): Uint8Array {
if (ink === 1 && this.plane1) return this.plane1;
return this.plane0;
}
setPixel(x: number, y: number, ink: Ink = 0): void {
if (x < 0 || y < 0 || x >= this.width || y >= this.height) return;
const i = y * this.rowStride + (x >> 3);
const mask = 0x80 >> (x & 7);
/* Last-write-wins on tri-state pixels: setting a pixel on one plane
* clears the same pixel on the OTHER plane. Without this, drawing
* black text on top of an accent-filled badge produces undefined
* results on the e-paper driver (typically the accent plane wins
* and the text vanishes). With this rule a pixel is always exactly
* one of {white, black, accent}, which is what plugin authors
* intuitively expect when stacking primitives. */
if (ink === 1 && this.plane1) {
this.plane1[i] |= mask;
this.plane0[i] &= ~mask;
} else {
this.plane0[i] |= mask;
if (this.plane1) this.plane1[i] &= ~mask;
}
}
clearPixel(x: number, y: number, ink: Ink = 0): void {
if (x < 0 || y < 0 || x >= this.width || y >= this.height) return;
const p = this.plane(ink);
const i = y * this.rowStride + (x >> 3);
p[i] &= ~(0x80 >> (x & 7));
}
/** Force a pixel to "white" (paper) by clearing BOTH planes. Useful
* for knockout text inside a coloured badge: white text on red
* background = no ink at all on those pixels, just the paper
* showing through. */
whitePixel(x: number, y: number): void {
if (x < 0 || y < 0 || x >= this.width || y >= this.height) return;
const i = y * this.rowStride + (x >> 3);
const mask = ~(0x80 >> (x & 7));
this.plane0[i] &= mask;
if (this.plane1) this.plane1[i] &= mask;
}
hline(x: number, y: number, w: number, ink: Ink = 0): void {
for (let i = 0; i < w; i++) this.setPixel(x + i, y, ink);
}
vline(x: number, y: number, h: number, ink: Ink = 0): void {
for (let i = 0; i < h; i++) this.setPixel(x, y + i, ink);
}
line(x0: number, y0: number, x1: number, y1: number, ink: Ink = 0): void {
// Bresenham
const dx = Math.abs(x1 - x0);
const sx = x0 < x1 ? 1 : -1;
const dy = -Math.abs(y1 - y0);
const sy = y0 < y1 ? 1 : -1;
let err = dx + dy;
while (true) {
this.setPixel(x0, y0, ink);
if (x0 === x1 && y0 === y1) break;
const e2 = 2 * err;
if (e2 >= dy) { err += dy; x0 += sx; }
if (e2 <= dx) { err += dx; y0 += sy; }
}
}
rect(x: number, y: number, w: number, h: number, ink: Ink = 0): void {
this.hline(x, y, w, ink);
this.hline(x, y + h - 1, w, ink);
this.vline(x, y, h, ink);
this.vline(x + w - 1, y, h, ink);
}
fillRect(x: number, y: number, w: number, h: number, ink: Ink = 0): void {
for (let yy = 0; yy < h; yy++) this.hline(x, y + yy, w, ink);
}
/* ---- Text -------------------------------------------------------- */
textSize(s: string, scale = 1): { w: number; h: number } {
return { w: s.length * (FONT_5x7_W + 1) * scale, h: FONT_5x7_H * scale };
}
drawText(x: number, y: number, s: string, ink: Ink = 0, scale = 1): void {
for (let ci = 0; ci < s.length; ci++) {
const ch = s.charCodeAt(ci);
let glyph: number[];
if (ch >= 32 && ch <= 127) {
glyph = FONT_5x7[ch - 32] ?? FONT_5x7[0];
} else {
/* Non-ASCII codepoint: try the extra glyph table (, £, ¥, , ...).
* Plugins can ship Unicode strings directly and this lookup
* keeps the per-glyph cell width identical, so textSize() math
* still works unchanged. */
glyph = FONT_EXTRA[ch] ?? FONT_5x7[0];
}
for (let row = 0; row < FONT_5x7_H; row++) {
const bits = glyph[row];
for (let col = 0; col < FONT_5x7_W; col++) {
if (bits & (1 << (FONT_5x7_W - 1 - col))) {
for (let dy = 0; dy < scale; dy++) {
for (let dx = 0; dx < scale; dx++) {
this.setPixel(
x + (ci * (FONT_5x7_W + 1) + col) * scale + dx,
y + row * scale + dy,
ink,
);
}
}
}
}
}
}
}
/** Knockout-text version of drawText: each glyph pixel is forced to
* white (clears BOTH planes). Use this when drawing a label inside
* a solid coloured badge so the paper shows through the letters. */
drawTextWhite(x: number, y: number, s: string, scale = 1): void {
for (let ci = 0; ci < s.length; ci++) {
const ch = s.charCodeAt(ci);
let glyph: number[];
if (ch >= 32 && ch <= 127) glyph = FONT_5x7[ch - 32] ?? FONT_5x7[0];
else glyph = FONT_EXTRA[ch] ?? FONT_5x7[0];
for (let row = 0; row < FONT_5x7_H; row++) {
const bits = glyph[row];
for (let col = 0; col < FONT_5x7_W; col++) {
if (bits & (1 << (FONT_5x7_W - 1 - col))) {
for (let dy = 0; dy < scale; dy++) {
for (let dx = 0; dx < scale; dx++) {
this.whitePixel(
x + (ci * (FONT_5x7_W + 1) + col) * scale + dx,
y + row * scale + dy,
);
}
}
}
}
}
}
}
drawTextCentered(cx: number, y: number, s: string, ink: Ink = 0, scale = 1): void {
const { w } = this.textSize(s, scale);
this.drawText(cx - (w >> 1), y, s, ink, scale);
}
drawTextRight(rx: number, y: number, s: string, ink: Ink = 0, scale = 1): void {
const { w } = this.textSize(s, scale);
this.drawText(rx - w, y, s, ink, scale);
}
/* ---- Sparkline --------------------------------------------------- */
sparkline(
x: number, y: number, w: number, h: number,
samples: number[], ink: Ink = 0, dot = true,
): void {
if (samples.length < 2) return;
let lo = Infinity, hi = -Infinity;
for (const v of samples) {
if (v < lo) lo = v;
if (v > hi) hi = v;
}
if (hi === lo) hi = lo + 1;
const xs: number[] = [];
const ys: number[] = [];
for (let i = 0; i < samples.length; i++) {
const px = x + Math.round((i * (w - 1)) / (samples.length - 1));
const py = y + h - 1 - Math.round(((samples[i] - lo) * (h - 1)) / (hi - lo));
xs.push(px); ys.push(py);
}
for (let i = 1; i < samples.length; i++) {
this.line(xs[i - 1], ys[i - 1], xs[i], ys[i], ink);
}
if (dot) {
const lx = xs[xs.length - 1], ly = ys[ys.length - 1];
this.fillRect(lx - 1, ly - 1, 3, 3, ink);
}
}
/* ---- Floyd-Steinberg dither (image to 1bpp/2bpp) ----------------- */
/**
* Blit a pre-scaled grayscale (or RGB) image into the canvas with
* Floyd-Steinberg dithering. `gray` is row-major, 1 byte per pixel.
* `rgb` (optional) is row-major RGB triplets; when present and an accent
* mode is set, saturated red/yellow pixels are routed to plane 1.
*/
blitDithered(
dx: number, dy: number, dw: number, dh: number,
gray: Uint8Array, rgb: Uint8Array | null, sw: number, sh: number,
accentMode: "none" | "red" | "yellow",
): void {
// Nearest-neighbour scale source -> dest, then dither in place.
const buf = new Float32Array(dw * dh);
const accFlag = new Uint8Array(dw * dh);
for (let y = 0; y < dh; y++) {
const sy = Math.min(sh - 1, Math.floor((y * sh) / dh));
for (let x = 0; x < dw; x++) {
const sx = Math.min(sw - 1, Math.floor((x * sw) / dw));
const gi = sy * sw + sx;
buf[y * dw + x] = gray[gi];
if (rgb && accentMode !== "none") {
const ri = gi * 3;
const r = rgb[ri], g = rgb[ri + 1], b = rgb[ri + 2];
if (accentMode === "red" && r > 150 && g < 90 && b < 90) {
accFlag[y * dw + x] = 1;
} else if (accentMode === "yellow" && r > 180 && g > 150 && b < 100) {
accFlag[y * dw + x] = 1;
}
}
}
}
for (let y = 0; y < dh; y++) {
for (let x = 0; x < dw; x++) {
const i = y * dw + x;
const old = buf[i];
const isAccent = accFlag[i] === 1;
const newPx = old < 128 ? 0 : 255;
if (newPx === 0) {
this.setPixel(dx + x, dy + y, isAccent ? 1 : 0);
}
const err = old - newPx;
if (x + 1 < dw) buf[i + 1] += err * 7 / 16;
if (y + 1 < dh) {
if (x > 0) buf[i + dw - 1] += err * 3 / 16;
buf[i + dw] += err * 5 / 16;
if (x + 1 < dw) buf[i + dw + 1] += err * 1 / 16;
}
}
}
}
/* ---- Serialization ---------------------------------------------- */
/**
* Encode as the binary blob the ESP forwards to the Flipper:
* uint16 width LE, uint16 height LE, uint8 planes, uint8 reserved,
* uint16 row_stride LE,
* plane0 bytes,
* plane1 bytes (if planes == 2).
*/
toBytes(): Uint8Array {
const planeBytes = this.rowStride * this.height;
const total = 8 + planeBytes * this.planes;
const out = new Uint8Array(total);
const view = new DataView(out.buffer);
view.setUint16(0, this.width, true);
view.setUint16(2, this.height, true);
out[4] = this.planes;
out[5] = 0;
view.setUint16(6, this.rowStride, true);
out.set(this.plane0, 8);
if (this.plane1) out.set(this.plane1, 8 + planeBytes);
return out;
}
}
@@ -0,0 +1,142 @@
/*
* Compact 5x7 ASCII bitmap font (95 glyphs, 0x20..0x7E).
*
* Each glyph is 7 bytes. Each byte is a row, low 5 bits = pixels left to
* right (we shift starting at bit 4 down to bit 0). Stored MSB-aligned in
* a uint8 by left-shifting 3 bits at runtime (we just mask `(bits >> n)`
* with FONT_5x7_W).
*
* Mirrors esp32-wifi-fw/main/font_5x7.c so designs look identical whether
* a future plugin is rendered server-side here or fell back to the device.
*/
export const FONT_5x7_W = 5;
export const FONT_5x7_H = 7;
// Each entry: 7 row bytes, low 5 bits = pixel row left -> right.
// Columns shifted to bits [4..0]. We test `bits & (1 << (4 - col))` in canvas.
export const FONT_5x7: number[][] = [
[0,0,0,0,0,0,0], // ' '
[0x04,0x04,0x04,0x04,0x00,0x00,0x04], // '!'
[0x0a,0x0a,0x00,0x00,0x00,0x00,0x00], // '"'
[0x0a,0x1f,0x0a,0x1f,0x0a,0x00,0x00], // '#'
[0x04,0x0f,0x14,0x0e,0x05,0x1e,0x04], // '$'
[0x18,0x19,0x02,0x04,0x08,0x13,0x03], // '%'
[0x08,0x14,0x14,0x08,0x15,0x12,0x0d], // '&'
[0x04,0x04,0x00,0x00,0x00,0x00,0x00], // '''
[0x02,0x04,0x08,0x08,0x08,0x04,0x02], // '('
[0x08,0x04,0x02,0x02,0x02,0x04,0x08], // ')'
[0x00,0x04,0x15,0x0e,0x15,0x04,0x00], // '*'
[0x00,0x04,0x04,0x1f,0x04,0x04,0x00], // '+'
[0x00,0x00,0x00,0x00,0x06,0x06,0x04], // ','
[0x00,0x00,0x00,0x1f,0x00,0x00,0x00], // '-'
[0x00,0x00,0x00,0x00,0x00,0x06,0x06], // '.'
[0x00,0x01,0x02,0x04,0x08,0x10,0x00], // '/'
[0x0e,0x11,0x13,0x15,0x19,0x11,0x0e], // '0'
[0x04,0x0c,0x04,0x04,0x04,0x04,0x0e], // '1'
[0x0e,0x11,0x01,0x02,0x04,0x08,0x1f], // '2'
[0x1f,0x02,0x04,0x02,0x01,0x11,0x0e], // '3'
[0x02,0x06,0x0a,0x12,0x1f,0x02,0x02], // '4'
[0x1f,0x10,0x1e,0x01,0x01,0x11,0x0e], // '5'
[0x06,0x08,0x10,0x1e,0x11,0x11,0x0e], // '6'
[0x1f,0x01,0x02,0x04,0x08,0x08,0x08], // '7'
[0x0e,0x11,0x11,0x0e,0x11,0x11,0x0e], // '8'
[0x0e,0x11,0x11,0x0f,0x01,0x02,0x0c], // '9'
[0x00,0x06,0x06,0x00,0x06,0x06,0x00], // ':'
[0x00,0x06,0x06,0x00,0x06,0x06,0x04], // ';'
[0x02,0x04,0x08,0x10,0x08,0x04,0x02], // '<'
[0x00,0x00,0x1f,0x00,0x1f,0x00,0x00], // '='
[0x08,0x04,0x02,0x01,0x02,0x04,0x08], // '>'
[0x0e,0x11,0x01,0x02,0x04,0x00,0x04], // '?'
[0x0e,0x11,0x17,0x15,0x17,0x10,0x0e], // '@'
[0x0e,0x11,0x11,0x1f,0x11,0x11,0x11], // 'A'
[0x1e,0x11,0x11,0x1e,0x11,0x11,0x1e], // 'B'
[0x0e,0x11,0x10,0x10,0x10,0x11,0x0e], // 'C'
[0x1c,0x12,0x11,0x11,0x11,0x12,0x1c], // 'D'
[0x1f,0x10,0x10,0x1e,0x10,0x10,0x1f], // 'E'
[0x1f,0x10,0x10,0x1e,0x10,0x10,0x10], // 'F'
[0x0e,0x11,0x10,0x17,0x11,0x11,0x0e], // 'G'
[0x11,0x11,0x11,0x1f,0x11,0x11,0x11], // 'H'
[0x0e,0x04,0x04,0x04,0x04,0x04,0x0e], // 'I'
[0x07,0x02,0x02,0x02,0x02,0x12,0x0c], // 'J'
[0x11,0x12,0x14,0x18,0x14,0x12,0x11], // 'K'
[0x10,0x10,0x10,0x10,0x10,0x10,0x1f], // 'L'
[0x11,0x1b,0x15,0x15,0x11,0x11,0x11], // 'M'
[0x11,0x11,0x19,0x15,0x13,0x11,0x11], // 'N'
[0x0e,0x11,0x11,0x11,0x11,0x11,0x0e], // 'O'
[0x1e,0x11,0x11,0x1e,0x10,0x10,0x10], // 'P'
[0x0e,0x11,0x11,0x11,0x15,0x12,0x0d], // 'Q'
[0x1e,0x11,0x11,0x1e,0x14,0x12,0x11], // 'R'
[0x0e,0x11,0x10,0x0e,0x01,0x11,0x0e], // 'S'
[0x1f,0x04,0x04,0x04,0x04,0x04,0x04], // 'T'
[0x11,0x11,0x11,0x11,0x11,0x11,0x0e], // 'U'
[0x11,0x11,0x11,0x11,0x11,0x0a,0x04], // 'V'
[0x11,0x11,0x11,0x15,0x15,0x15,0x0a], // 'W'
[0x11,0x11,0x0a,0x04,0x0a,0x11,0x11], // 'X'
[0x11,0x11,0x11,0x0a,0x04,0x04,0x04], // 'Y'
[0x1f,0x01,0x02,0x04,0x08,0x10,0x1f], // 'Z'
[0x0e,0x08,0x08,0x08,0x08,0x08,0x0e], // '['
[0x00,0x10,0x08,0x04,0x02,0x01,0x00], // '\'
[0x0e,0x02,0x02,0x02,0x02,0x02,0x0e], // ']'
[0x04,0x0a,0x11,0x00,0x00,0x00,0x00], // '^'
[0x00,0x00,0x00,0x00,0x00,0x00,0x1f], // '_'
[0x08,0x04,0x02,0x00,0x00,0x00,0x00], // '`'
[0x00,0x00,0x0e,0x01,0x0f,0x11,0x0f], // 'a'
[0x10,0x10,0x16,0x19,0x11,0x11,0x1e], // 'b'
[0x00,0x00,0x0e,0x10,0x10,0x11,0x0e], // 'c'
[0x01,0x01,0x0d,0x13,0x11,0x11,0x0f], // 'd'
[0x00,0x00,0x0e,0x11,0x1f,0x10,0x0e], // 'e'
[0x06,0x09,0x08,0x1c,0x08,0x08,0x08], // 'f'
[0x00,0x0f,0x11,0x11,0x0f,0x01,0x0e], // 'g'
[0x10,0x10,0x16,0x19,0x11,0x11,0x11], // 'h'
[0x04,0x00,0x0c,0x04,0x04,0x04,0x0e], // 'i'
[0x02,0x00,0x06,0x02,0x02,0x12,0x0c], // 'j'
[0x10,0x10,0x12,0x14,0x18,0x14,0x12], // 'k'
[0x0c,0x04,0x04,0x04,0x04,0x04,0x0e], // 'l'
[0x00,0x00,0x1a,0x15,0x15,0x11,0x11], // 'm'
[0x00,0x00,0x16,0x19,0x11,0x11,0x11], // 'n'
[0x00,0x00,0x0e,0x11,0x11,0x11,0x0e], // 'o'
[0x00,0x1e,0x11,0x11,0x1e,0x10,0x10], // 'p'
[0x00,0x0d,0x13,0x13,0x0d,0x01,0x01], // 'q'
[0x00,0x00,0x16,0x19,0x10,0x10,0x10], // 'r'
[0x00,0x00,0x0f,0x10,0x0e,0x01,0x1e], // 's'
[0x08,0x08,0x1c,0x08,0x08,0x09,0x06], // 't'
[0x00,0x00,0x11,0x11,0x11,0x13,0x0d], // 'u'
[0x00,0x00,0x11,0x11,0x11,0x0a,0x04], // 'v'
[0x00,0x00,0x11,0x11,0x15,0x15,0x0a], // 'w'
[0x00,0x00,0x11,0x0a,0x04,0x0a,0x11], // 'x'
[0x00,0x00,0x11,0x11,0x0f,0x01,0x0e], // 'y'
[0x00,0x00,0x1f,0x02,0x04,0x08,0x1f], // 'z'
[0x02,0x04,0x04,0x08,0x04,0x04,0x02], // '{'
[0x04,0x04,0x04,0x00,0x04,0x04,0x04], // '|'
[0x08,0x04,0x04,0x02,0x04,0x04,0x08], // '}'
[0x09,0x15,0x12,0x00,0x00,0x00,0x00], // '~'
[0,0,0,0,0,0,0], // 0x7f (fallback)
];
/* Extra non-ASCII glyphs the canvas will render via direct codepoint
* lookup. Each entry is a 7-row 5-wide bitmap in the same packing as
* FONT_5x7 (low 5 bits = pixels left-to-right). Keyed by Unicode code
* point so plugins can write `"€69,420"` literally and have it Just Work. */
export const FONT_EXTRA: Record<number, number[]> = {
0x00A3: [0x07,0x08,0x08,0x1E,0x08,0x08,0x1F], // £ pound
0x00A5: [0x11,0x0A,0x04,0x1F,0x04,0x1F,0x04], // ¥ yen
0x00A2: [0x04,0x0E,0x14,0x14,0x14,0x0E,0x04], // ¢ cent
0x20AC: [0x06,0x09,0x1E,0x08,0x1E,0x09,0x06], // € euro
0x00B0: [0x06,0x09,0x06,0x00,0x00,0x00,0x00], // ° degree
0x2191: [0x04,0x0E,0x15,0x04,0x04,0x04,0x04], // ↑ up arrow
0x2193: [0x04,0x04,0x04,0x04,0x15,0x0E,0x04], // ↓ down arrow
0x25B2: [0x04,0x04,0x0E,0x0E,0x1F,0x1F,0x00], // ▲ filled up
0x25BC: [0x00,0x1F,0x1F,0x0E,0x0E,0x04,0x04], // ▼ filled down
0x2022: [0x00,0x00,0x0E,0x1F,0x0E,0x00,0x00], // • bullet
0x25C9: [0x0E,0x11,0x15,0x1B,0x15,0x11,0x0E], // ◉ fisheye
0x25EF: [0x0E,0x11,0x11,0x11,0x11,0x11,0x0E], // ◯ large circle
0x25C6: [0x04,0x0E,0x1F,0x1F,0x1F,0x0E,0x04], // ◆ diamond
0x25A0: [0x00,0x1F,0x1F,0x1F,0x1F,0x1F,0x00], // ■ filled square
0x25A1: [0x00,0x1F,0x11,0x11,0x11,0x1F,0x00], // □ empty square
0x2588: [0x1F,0x1F,0x1F,0x1F,0x1F,0x1F,0x1F], // █ full block
0x2592: [0x15,0x0A,0x15,0x0A,0x15,0x0A,0x15], // ▒ medium shade
0x253C: [0x04,0x04,0x04,0x1F,0x04,0x04,0x04], // ┼ crosshair
0x2605: [0x04,0x04,0x1F,0x0E,0x0E,0x11,0x00], // ★ black star
0x2606: [0x04,0x0A,0x11,0x0A,0x0A,0x11,0x00], // ☆ white star (rough)
};
@@ -0,0 +1,113 @@
/*
* Shared image-fetch / decode / dither helpers used by every plugin
* that wants to render a downloaded raster onto the e-paper canvas.
*
* - fetchImageGray(url): downloads, sniffs PNG vs JPEG, decodes, then
* composites alpha over white and converts to perceptual luma. The
* result is always a tightly packed Uint8Array of grayscale 0..255.
* - blitGrayDither(canvas, ...): nearest-neighbour scales a grayscale
* buffer into a destination rect on a Canvas and serpentine
* Floyd-Steinberg dithers it to monochrome ink in the process.
*
* Keeping these in one file means the worker bundle ships UPNG / jpeg-js
* once, not once per plugin, and any pixel-level improvements (e.g.
* a different dither) propagate everywhere automatically.
*/
import * as UPNG from "upng-js";
// jpeg-js is CommonJS without proper types, so import as any.
// eslint-disable-next-line @typescript-eslint/no-var-requires
import jpegJs from "jpeg-js";
import { Canvas, Ink } from "./canvas";
export interface GrayImage { gray: Uint8Array; w: number; h: number; }
/** Fetch a remote image and reduce it to a grayscale luma buffer. PNG
* and JPEG are both supported - we sniff the magic bytes rather than
* trusting the URL extension or the Content-Type header (which CDNs
* frequently get wrong). */
export async function fetchImageGray(url: string): Promise<GrayImage | null> {
let raw: ArrayBuffer;
try {
const r = await fetch(url, { headers: { "User-Agent": "TagTinker/1.0" } });
if (!r.ok) return null;
raw = await r.arrayBuffer();
} catch {
return null;
}
return decodeImageGray(raw);
}
/** Decode a buffer into grayscale, sniffing PNG vs JPEG on first bytes. */
export function decodeImageGray(buf: ArrayBuffer): GrayImage | null {
const u8 = new Uint8Array(buf);
if (u8.length < 4) return null;
let rgba: Uint8Array;
let w: number, h: number;
/* PNG magic: 89 50 4E 47 */
if (u8[0] === 0x89 && u8[1] === 0x50 && u8[2] === 0x4E && u8[3] === 0x47) {
let dec;
try { dec = UPNG.decode(buf); } catch { return null; }
const arr = UPNG.toRGBA8(dec);
if (!arr.length) return null;
rgba = new Uint8Array(arr[0]);
w = dec.width; h = dec.height;
/* JPEG magic: FF D8 FF */
} else if (u8[0] === 0xFF && u8[1] === 0xD8 && u8[2] === 0xFF) {
let dec;
try { dec = jpegJs.decode(u8, { useTArray: true }); } catch { return null; }
rgba = dec.data as Uint8Array;
w = dec.width; h = dec.height;
} else {
return null;
}
const gray = new Uint8Array(w * h);
for (let i = 0, j = 0; i < gray.length; i++, j += 4) {
const a = rgba[j + 3] / 255;
const r = rgba[j] * a + 255 * (1 - a);
const g = rgba[j + 1] * a + 255 * (1 - a);
const b = rgba[j + 2] * a + 255 * (1 - a);
gray[i] = (0.2126 * r + 0.7152 * g + 0.0722 * b) | 0;
}
return { gray, w, h };
}
/** Serpentine Floyd-Steinberg dither into a destination rect. The
* source is nearest-neighbour scaled into a working float buffer so
* the dither runs at output resolution (where artefacts actually
* matter to the user). */
export function blitGrayDither(
c: Canvas, dx: number, dy: number, dstW: number, dstH: number,
img: GrayImage, ink: Ink = 0,
): void {
const { gray, w: srcW, h: srcH } = img;
const buf = new Float32Array(dstW * dstH);
for (let y = 0; y < dstH; y++) {
const sy = Math.min(srcH - 1, Math.floor((y * srcH) / dstH));
for (let x = 0; x < dstW; x++) {
const sx = Math.min(srcW - 1, Math.floor((x * srcW) / dstW));
buf[y * dstW + x] = gray[sy * srcW + sx];
}
}
for (let y = 0; y < dstH; y++) {
const rev = (y & 1) === 1;
const xStart = rev ? dstW - 1 : 0;
const xEnd = rev ? -1 : dstW;
const xStep = rev ? -1 : 1;
for (let x = xStart; x !== xEnd; x += xStep) {
const i = y * dstW + x;
const old = buf[i];
const np = old < 128 ? 0 : 255;
if (np === 0) c.setPixel(dx + x, dy + y, ink);
const err = old - np;
const sx = rev ? -1 : 1;
if (x + sx >= 0 && x + sx < dstW) buf[i + sx] += err * 7 / 16;
if (y + 1 < dstH) {
if (x - sx >= 0 && x - sx < dstW) buf[i + dstW - sx] += err * 3 / 16;
buf[i + dstW] += err * 5 / 16;
if (x + sx >= 0 && x + sx < dstW) buf[i + dstW + sx] += err * 1 / 16;
}
}
}
}
@@ -0,0 +1,108 @@
/*
* TagTinker WiFi Plugins - Cloudflare Worker entry.
*
* Endpoints:
*
* GET /plugins
* -> JSON: { plugins: [ {id,name,description,accent_modes,params}, ... ] }
*
* GET /render/:id?w=<int>&h=<int>&accent=<none|red|yellow>&<param>=<val>...
* -> application/octet-stream
* Format (little-endian):
* uint16 width, uint16 height, uint8 planes, uint8 reserved,
* uint16 row_stride,
* plane0 bytes (rowStride * height),
* plane1 bytes (if planes == 2).
*
* The ESP32 simply forwards the byte stream to the Flipper as a
* RESULT_BEGIN + N x RESULT_CHUNK + RESULT_END frame sequence.
*/
import { Plugin, AccentMode } from "./plugin";
import { cryptoPlugin } from "./plugins/crypto";
import { weatherPlugin } from "./plugins/weather";
import { identiconPlugin } from "./plugins/identicon";
import { githubPlugin } from "./plugins/github";
const PLUGINS: Plugin[] = [
cryptoPlugin, weatherPlugin, identiconPlugin, githubPlugin,
];
const CORS_HEADERS: Record<string, string> = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
};
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
});
}
function errorResponse(message: string, status = 400): Response {
return jsonResponse({ error: message }, status);
}
function parseAccent(s: string | null): AccentMode {
return s === "red" || s === "yellow" ? s : "none";
}
async function handleRender(id: string, url: URL): Promise<Response> {
const plugin = PLUGINS.find((p) => p.manifest.id === id);
if (!plugin) return errorResponse(`unknown plugin '${id}'`, 404);
const w = parseInt(url.searchParams.get("w") ?? "296", 10);
const h = parseInt(url.searchParams.get("h") ?? "128", 10);
if (!(w > 0 && w <= 1024 && h > 0 && h <= 1024)) {
return errorResponse("bad w/h", 400);
}
const accent = parseAccent(url.searchParams.get("accent"));
const params: Record<string, string> = {};
for (const [k, v] of url.searchParams.entries()) {
if (k !== "w" && k !== "h" && k !== "accent") params[k] = v;
}
try {
const c = await plugin.render(params, w, h, accent);
const bytes = c.toBytes();
return new Response(bytes, {
status: 200,
headers: {
"Content-Type": "application/octet-stream",
"Cache-Control": "no-store",
...CORS_HEADERS,
},
});
} catch (e: any) {
return errorResponse(`render failed: ${e?.message ?? e}`, 500);
}
}
export default {
async fetch(req: Request): Promise<Response> {
const url = new URL(req.url);
if (req.method === "OPTIONS") {
return new Response(null, { status: 204, headers: CORS_HEADERS });
}
if (url.pathname === "/" || url.pathname === "/health") {
return jsonResponse({
ok: true,
service: "tagtinker-cloud-plugins",
plugins: PLUGINS.length,
});
}
if (url.pathname === "/plugins") {
return jsonResponse({ plugins: PLUGINS.map((p) => p.manifest) });
}
const m = url.pathname.match(/^\/render\/([a-zA-Z0-9_-]+)$/);
if (m) return handleRender(m[1], url);
return errorResponse("not found", 404);
},
};
@@ -0,0 +1,43 @@
/*
* Plugin shape. A plugin exports:
* - manifest: served verbatim from /plugins
* - render(params, w, h, accent): builds a Canvas and returns it
*
* Adding a new plugin = drop a new file in src/plugins/, import + push it
* to PLUGINS in src/index.ts. Cloudflare deploys, Flipper picks it up on
* the next "Refresh Plugins".
*/
import { Canvas } from "./canvas";
export type ParamType = "string" | "int" | "enum" | "bool";
export interface ParamSpec {
key: string;
label: string;
type: ParamType;
default: string;
options?: string[];
min?: number;
max?: number;
}
export const ACCENT_NONE = 0;
export const ACCENT_RED = 1;
export const ACCENT_YELLOW = 2;
export interface PluginManifest {
id: string;
name: string;
description: string;
/** Bitmask: 1 = mono OK, 2 = red, 4 = yellow */
accent_modes: number;
params: ParamSpec[];
}
export type AccentMode = "none" | "red" | "yellow";
export interface Plugin {
manifest: PluginManifest;
render(params: Record<string, string>, w: number, h: number, accent: AccentMode): Promise<Canvas>;
}
@@ -0,0 +1,256 @@
/*
* Crypto Price plugin - editorial / share-worthy layout.
*
* Mono tags get a clean black & white card; tri-colour tags get a
* dramatic accent treatment on the sparkline fill, the delta badge,
* the corner brackets and a thin underline accent on the price - the
* kind of thing that looks intentional in a photo of an e-paper tag.
*/
import { Canvas, Ink } from "../canvas";
import { Plugin, AccentMode } from "../plugin";
const COIN_MAP: Record<string, string> = {
BTC: "bitcoin", ETH: "ethereum", SOL: "solana", XRP: "ripple",
DOGE: "dogecoin", ADA: "cardano", BNB: "binancecoin", LINK: "chainlink",
};
const RANGE_DAYS: Record<string, number> = { "24H": 1, "7D": 7, "30D": 30 };
/* The Canvas now ships €/£/¥ glyphs in FONT_EXTRA so we can render them
* literally as the price prefix - same width budget as "$". */
const CCY_PREFIX: Record<string, string> = { USD: "$", EUR: "\u20AC", GBP: "\u00A3" };
function formatPrice(v: number, prefix: string): string {
let body: string;
if (v >= 1) body = v.toFixed(2);
else if (v >= 0.01) body = v.toFixed(4);
else body = v.toFixed(6);
const [intPart, frac] = body.split(".");
const withCommas = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
return prefix + withCommas + (frac ? "." + frac : "");
}
async function fetchPrice(id: string, vs: string): Promise<{ price: number; change: number }> {
const url = `https://api.coingecko.com/api/v3/simple/price?ids=${id}&vs_currencies=${vs}&include_24hr_change=true`;
const r = await fetch(url, { headers: { "User-Agent": "TagTinker/1.0" } });
if (!r.ok) throw new Error(`price ${r.status}`);
const j: any = await r.json();
const c = j[id];
if (!c) throw new Error("coin not in response");
const k = vs.toLowerCase();
return { price: c[k] ?? 0, change: c[`${k}_24h_change`] ?? 0 };
}
async function fetchHistory(id: string, vs: string, days: number, samples: number): Promise<number[]> {
const url = `https://api.coingecko.com/api/v3/coins/${id}/market_chart?vs_currency=${vs.toLowerCase()}&days=${days}`;
const r = await fetch(url, { headers: { "User-Agent": "TagTinker/1.0" } });
if (!r.ok) throw new Error(`history ${r.status}`);
const j: any = await r.json();
const arr: [number, number][] = j.prices ?? [];
if (arr.length === 0) return [];
const out: number[] = [];
for (let i = 0; i < samples; i++) {
const idx = Math.floor((i * (arr.length - 1)) / Math.max(1, samples - 1));
out.push(arr[idx][1]);
}
return out;
}
/* ─── Drawing helpers ─────────────────────────────────────────────── */
const BAYER4 = [
[ 0, 8, 2, 10],
[12, 4, 14, 6],
[ 3, 11, 1, 9],
[15, 7, 13, 5],
];
function fillStippled(c: Canvas, x: number, y: number, w: number, h: number,
density: number, ink: Ink) {
const t = Math.max(0, Math.min(15, Math.round(density * 16) - 1));
for (let yy = 0; yy < h; yy++) {
for (let xx = 0; xx < w; xx++) {
if (BAYER4[yy & 3][xx & 3] <= t) c.setPixel(x + xx, y + yy, ink);
}
}
}
function fillUnderCurve(c: Canvas, xs: number[], ys: number[],
baselineY: number, ink: Ink) {
for (let i = 0; i < xs.length - 1; i++) {
const x0 = xs[i], y0 = ys[i];
const x1 = xs[i + 1], y1 = ys[i + 1];
const dx = x1 - x0;
if (dx <= 0) continue;
for (let x = x0; x <= x1; x++) {
const t = (x - x0) / dx;
const y = Math.round(y0 + (y1 - y0) * t);
const top = Math.min(y, baselineY);
const bot = Math.max(y, baselineY);
for (let yy = top; yy <= bot; yy++) {
if (BAYER4[yy & 3][x & 3] < 8) c.setPixel(x, yy, ink);
}
}
}
}
function cornerBrackets(c: Canvas, x: number, y: number, w: number, h: number,
len: number, ink: Ink) {
c.hline(x, y, len, ink); c.vline(x, y, len, ink);
c.hline(x + w - len, y, len, ink); c.vline(x + w - 1, y, len, ink);
c.hline(x, y + h - 1, len, ink); c.vline(x, y + h - len, len, ink);
c.hline(x + w - len, y + h - 1, len, ink); c.vline(x + w - 1, y + h - len, len, ink);
}
/* ─── Plugin ─────────────────────────────────────────────────────── */
export const cryptoPlugin: Plugin = {
manifest: {
id: "crypto",
name: "Crypto Price",
description: "Editorial price card with sparkline",
accent_modes: 1 | 2 | 4,
params: [
{ key: "symbol", label: "Coin", type: "enum", default: "BTC",
options: Object.keys(COIN_MAP) },
{ key: "currency", label: "Currency", type: "enum", default: "USD",
options: Object.keys(CCY_PREFIX) },
{ key: "range", label: "Range", type: "enum", default: "24H",
options: Object.keys(RANGE_DAYS) },
],
},
async render(params, W, H, accent: AccentMode) {
const sym = (params.symbol ?? "BTC").toUpperCase();
const vs = (params.currency ?? "USD").toUpperCase();
const range = (params.range ?? "24H").toUpperCase();
const id = COIN_MAP[sym] ?? "bitcoin";
const days = RANGE_DAYS[range] ?? 1;
const prefix = CCY_PREFIX[vs] ?? "$";
const { price, change } = await fetchPrice(id, vs);
const samples = Math.min(W, 256);
const hist = await fetchHistory(id, vs, days, samples);
const planes: 1 | 2 = accent === "none" ? 1 : 2;
const c = new Canvas(W, H, planes);
/* On mono tags accent ink == primary - visually a bit boring but the
* structural design (corner brackets, dithered fills, big type) still
* carries the look. On tri-colour tags the accent plane gives the
* splash of red/yellow that makes the card pop. */
const acc: Ink = planes === 2 ? 1 : 0;
const blk: Ink = 0;
const margin = W < 200 ? 4 : 8;
/* ── Corner viewfinder brackets in accent ─────────────────────── */
const bracketLen = Math.max(8, Math.min(16, Math.floor(W / 24)));
cornerBrackets(c, 1, 1, W - 2, H - 2, bracketLen, acc);
/* ── Header row: symbol/ccy on the left, delta badge right ────── */
const headerY = margin + 2;
const symLabel = `${sym}/${vs}`;
/* Header: "BTC/USD" in scale 2 - the focal element of the top bar. */
c.drawText(margin + 4, headerY, symLabel, blk, 2);
const symH = c.textSize(symLabel, 2).h;
/* Range chip on header row right of the symbol. */
{
const chip = range;
const cs = c.textSize(chip, 1);
const cx = margin + 4 + c.textSize(symLabel, 2).w + 6;
const cy = headerY + Math.max(0, (symH - cs.h) >> 1);
/* Box outline in primary, the chip text reads black on white. */
c.rect(cx - 2, cy - 1, cs.w + 4, cs.h + 2, blk);
c.drawText(cx, cy, chip, blk, 1);
}
/* Delta badge top-right: solid accent block with a chunky black
* triangle and the % delta in black, all rendered on plane 0 over
* the accent plane fill. Reads cleanly as "black ink on red/yellow
* paint" on tri-colour tags - and as plain black on mono tags. */
{
const arrow = change >= 0 ? "\u25B2" : "\u25BC"; // ▲ / ▼ glyph
const pct = `${change >= 0 ? "+" : "-"}${Math.abs(change).toFixed(2)}%`;
const arrowSize = c.textSize(arrow, 1);
const pctSize = c.textSize(pct, 1);
const padX = 5;
const gap = 3;
const bw = padX + arrowSize.w + gap + pctSize.w + padX;
const bh = Math.max(arrowSize.h, pctSize.h) + 6;
const bx = W - margin - bw - 2;
const by = headerY + Math.max(0, (symH - bh) >> 1);
/* Solid accent fill behind everything, then knock out the arrow
* and the % text in WHITE (paper showing through both planes).
* White-on-red has way higher contrast than black-on-red on the
* actual e-paper - the black ink and the red ink absorb similar
* amounts of light, so black text on red reads as a muddy
* monochrome blob. White (paper) on red is the print-magazine
* standard for a reason. */
c.fillRect(bx, by, bw, bh, acc);
const ax = bx + padX;
const ay = by + ((bh - arrowSize.h) >> 1);
c.drawTextWhite(ax, ay, arrow, 1);
const px = ax + arrowSize.w + gap;
const py = by + ((bh - pctSize.h) >> 1);
c.drawTextWhite(px, py, pct, 1);
}
/* ── Big price ─────────────────────────────────────────────── */
const priceTxt = formatPrice(price, prefix);
/* Pick the largest scale that fits. */
let pscale = W >= 280 ? 4 : W >= 200 ? 3 : 2;
while (pscale > 1 && c.textSize(priceTxt, pscale).w > W - 2 * margin - 6) pscale--;
const pSize = c.textSize(priceTxt, pscale);
const priceY = headerY + symH + Math.max(6, Math.floor(H / 14));
const priceX = (W - pSize.w) >> 1;
c.drawText(priceX, priceY, priceTxt, blk, pscale);
/* ── Sparkline + accent fill ─────────────────────────────── */
if (hist.length >= 2) {
/* No underline now - the price headline breathes and the sparkline
* gets the full lower half of the card. */
const sparkTop = priceY + pSize.h + Math.max(6, Math.floor(H / 14));
const sparkBot = H - margin - 4;
const sparkH = sparkBot - sparkTop;
const sparkX = margin + 4;
const sparkW = W - 2 * (margin + 4);
if (sparkH > 8) {
let lo = Infinity, hi = -Infinity;
for (const v of hist) { if (v < lo) lo = v; if (v > hi) hi = v; }
if (hi === lo) hi = lo + 1;
const xs: number[] = [];
const ys: number[] = [];
for (let i = 0; i < hist.length; i++) {
const px = sparkX + Math.round((i * (sparkW - 1)) / (hist.length - 1));
const py = sparkTop + sparkH - 1 -
Math.round(((hist[i] - lo) * (sparkH - 1)) / (hi - lo));
xs.push(px); ys.push(py);
}
/* Accent stippled fill below the curve - the visual hero. */
fillUnderCurve(c, xs, ys, sparkBot - 1, acc);
/* Curve itself in BLACK, drawn thick so it pops over the
* stippled fill - this is the "real" line the eye reads. */
for (let i = 1; i < xs.length; i++) {
c.line(xs[i - 1], ys[i - 1], xs[i], ys[i], blk);
/* Add a 1-pixel "halo" above for extra weight. */
if (ys[i] > sparkTop)
c.line(xs[i - 1], ys[i - 1] - 1, xs[i], ys[i] - 1, blk);
}
/* Baseline rule. */
c.hline(sparkX, sparkBot, sparkW, blk);
/* Latest value pin: small accent dot + black ring. */
const lx = xs[xs.length - 1], ly = ys[ys.length - 1];
c.fillRect(lx - 2, ly - 2, 5, 5, acc);
c.rect(lx - 2, ly - 2, 5, 5, blk);
}
}
return c;
},
};
@@ -0,0 +1,337 @@
/*
* GitHub Profile plugin - "trading card" badge.
*
* Tuned for the 208x112 tag (the common case); scales to any size.
*
*
* @torvalds accent stripe + knockout
*
* AVATAR LINUS TORVALDS scale-2 name
* round Creator of Linux optional 1-line bio
*
*
* 178K 234 712
* STARS FOLLOWERS REPOS
*
*
* Palette:
* - Accent: top stripe + the STARS number.
* - Black: typography, frame, dividers, dithered avatar.
* - White: paper, knockout @handle in the stripe.
*
* Data: api.github.com only - the heatmap API was the slowest leg of
* the previous render and the trading-card layout has no heatmap, so
* we drop that fetch entirely. Render p99 went from ~1.4s to ~600ms.
*/
import { Canvas, Ink } from "../canvas";
import { Plugin, AccentMode } from "../plugin";
import { fetchImageGray, blitGrayDither } from "../image_util";
interface GhUser {
login: string;
name: string | null;
bio: string | null;
avatar_url: string;
followers: number;
public_repos: number;
}
interface GhRepoLite { stargazers_count: number; }
async function fetchUser(login: string): Promise<GhUser | null> {
try {
const r = await fetch(`https://api.github.com/users/${encodeURIComponent(login)}`, {
headers: { "User-Agent": "TagTinker/1.0", "Accept": "application/vnd.github+json" },
});
if (!r.ok) return null;
return await r.json() as GhUser;
} catch { return null; }
}
async function fetchTotalStars(login: string): Promise<number | null> {
try {
const r = await fetch(
`https://api.github.com/users/${encodeURIComponent(login)}/repos` +
`?per_page=100&sort=pushed`,
{ headers: { "User-Agent": "TagTinker/1.0", "Accept": "application/vnd.github+json" } });
if (!r.ok) return null;
const repos: GhRepoLite[] = await r.json();
let total = 0;
for (const r of repos) total += r.stargazers_count | 0;
return total;
} catch { return null; }
}
function compact(n: number): string {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(n < 10_000_000 ? 1 : 0) + "M";
if (n >= 1_000) return (n / 1_000).toFixed(n < 10_000 ? 1 : 0) + "K";
return String(n);
}
function ascii(s: string): string {
return s
.replace(/[\u2018\u2019\u02BC]/g, "'")
.replace(/[\u201C\u201D]/g, '"')
.replace(/[\u2013\u2014]/g, "-")
.replace(/\u2026/g, "...")
.replace(/[^\x20-\x7E]/g, "");
}
function truncate(s: string, max: number): string {
if (max <= 0) return "";
if (s.length <= max) return s;
if (max <= 3) return s.slice(0, max);
return s.slice(0, max - 3) + "...";
}
/* Deterministic filler lines for users with no bio. The choice is
* keyed on the login so the same person always gets the same quip
* across renders (otherwise it'd flicker between each refresh and
* read as a glitch rather than a feature). Kept short so it fits
* on one line at the 208x112 column width (~22 chars). */
const BIO_FILLERS: string[] = [
"no bio. just vibes.",
"git push origin self",
"probably refactoring.",
"404: bio not found.",
"lurking in repos.",
"shipping, not updating.",
"lives in the terminal.",
"touch grass? maybe later.",
"compiling thoughts...",
"reads code for fun.",
"bio loading...",
"rm -rf /writers-block",
];
function pickFiller(login: string): string {
let h = 0;
for (let i = 0; i < login.length; i++) h = (h * 31 + login.charCodeAt(i)) | 0;
return BIO_FILLERS[Math.abs(h) % BIO_FILLERS.length];
}
/** Greedy word-wrap into at most `maxLines` lines of `maxChars`.
* Words that don't fit the current line are bumped to the next
* line (never split mid-word). Anything that doesn't fit in the
* vertical budget is dropped silently - no ellipsis, no mid-word
* cut, the bio just stops on a clean word break. */
function wrapText(s: string, maxChars: number, maxLines: number): string[] {
if (maxChars <= 0 || maxLines <= 0) return [];
const words = s.split(/\s+/).filter(Boolean);
const lines: string[] = [];
let cur = "";
for (let i = 0; i < words.length && lines.length < maxLines; i++) {
const w = words[i];
const cand = cur ? cur + " " + w : w;
if (cand.length <= maxChars) {
cur = cand;
} else {
if (cur) {
lines.push(cur);
if (lines.length >= maxLines) { cur = ""; break; }
}
/* If a single word is wider than the column we still need to
* place it somewhere - otherwise the bio could go entirely
* blank for a user whose first word is e.g. an absurdly long
* URL. Hard-break it on column boundaries (rare path). */
if (w.length <= maxChars) {
cur = w;
} else {
let rest = w;
while (rest.length > maxChars && lines.length < maxLines) {
lines.push(rest.slice(0, maxChars));
rest = rest.slice(maxChars);
}
cur = lines.length < maxLines ? rest : "";
}
}
}
if (cur && lines.length < maxLines) lines.push(cur);
return lines;
}
export const githubPlugin: Plugin = {
manifest: {
id: "github",
name: "GitHub Profile",
description: "Trading-card style profile badge with stars/followers/repos",
accent_modes: 1 | 2 | 4,
params: [
{ key: "username", label: "Username", type: "string", default: "torvalds" },
],
},
async render(params, W, H, accent: AccentMode) {
const login = ((params.username ?? "torvalds").trim() || "torvalds");
const planes: 1 | 2 = accent === "none" ? 1 : 2;
const c = new Canvas(W, H, planes);
const acc: Ink = planes === 2 ? 1 : 0;
const blk: Ink = 0;
const [user, stars] = await Promise.all([
fetchUser(login),
fetchTotalStars(login),
]);
if (!user) {
const t1 = "USER NOT FOUND";
const t2 = "@" + login;
const s1 = c.textSize(t1, 2), s2 = c.textSize(t2, 1);
c.drawText((W - s1.w) >> 1, (H >> 1) - s1.h, t1, blk, 2);
c.drawText((W - s2.w) >> 1, (H >> 1) + 2, t2, blk, 1);
c.rect(0, 0, W, H, blk);
return c;
}
/* Geometry
* Three horizontal bands:
* 1. stripe - thin accent bar with knockout @handle
* 2. portrait - circular avatar + display name + (optional bio)
* 3. stats - 3-column number panel (stars / followers / repos)
* Coordinates are derived from W/H so a 296x128 tag gets a roomier
* version of the same card for free, but everything is sized so
* the 208x112 layout stays tight and balanced. */
const stripeH = 11;
const padX = 4;
/* Stats band height = scale-2 number (14) + 2-px gap + scale-1
* label (7) + 4-px breathing room top/bot = 31 px. We push the
* divider as far down as that constraint allows so the avatar
* gets the rest of the page. On 112-tall it lands at ~78. */
const statsBandH = 7 + 2 + 14 + 4 + 4;
const dividerY = H - statsBandH;
/* 1. Top accent stripe with knockout handle
* fillRect first (accent ink), then drawTextWhite punches the
* @handle out of it, leaving paper showing through the letters.
* Looks like a printed magazine title bar. */
c.fillRect(0, 0, W, stripeH, acc);
/* Show the full short URL ("github.com/<login>") - reads as a
* proper profile link instead of just an @handle. If the login
* is too long for the column we drop back to "@<login>". */
const fullLink = "github.com/" + ascii(user.login);
const linkMax = Math.max(1, Math.floor((W - 12) / 6));
const linkStr = fullLink.length <= linkMax
? fullLink
: truncate("@" + ascii(user.login), linkMax);
const linkSize = c.textSize(linkStr, 1);
c.drawTextWhite((W - linkSize.w) >> 1, (stripeH - 7) >> 1, linkStr, 1);
/* 2. Portrait band: square avatar + name + bio
* Avatar is a clean square so it gets the full pixel budget
* for the dithered face (no corners wasted to a circle mask).
* Size scales with the band height so a wider tag gets a
* bigger picture automatically. */
const bandTop = stripeH + 2;
const bandBot = dividerY - 2;
const bandH = bandBot - bandTop;
const avSize = Math.min(64, bandH); // 60 on 112-tall
const avX = padX + 4;
const avY = bandTop + ((bandH - avSize) >> 1);
const avatarUrl = user.avatar_url.includes("?")
? `${user.avatar_url}&s=${avSize * 2}`
: `${user.avatar_url}?s=${avSize * 2}`;
const avatar = await fetchImageGray(avatarUrl);
if (avatar) {
blitGrayDither(c, avX + 1, avY + 1, avSize - 2, avSize - 2, avatar, blk);
} else {
const ini = (user.login.charAt(0) || "?").toUpperCase();
const sz = c.textSize(ini, 4);
c.drawText(avX + ((avSize - sz.w) >> 1),
avY + ((avSize - sz.h) >> 1), ini, blk, 4);
}
/* 1-px square frame so the dithered face has a clean edge. */
c.rect(avX, avY, avSize, avSize, blk);
/* Name + (optional) bio, right of the avatar. */
const tX = avX + avSize + 8;
const tW = W - tX - padX;
const nameRaw = (user.name && user.name.trim() ? user.name : user.login).trim();
const nameAscii = ascii(nameRaw).toUpperCase();
/* Prefer scale 2; drop to scale 1 if it doesn't fit. Bold is
* faux'd by drawing the glyphs twice with a 1-px x-offset -
* a classic bitmap-font trick that keeps the 5x7 readable
* while giving the name visible weight. */
const max2 = Math.max(1, Math.floor(tW / 12));
const max1 = Math.max(1, Math.floor(tW / 6));
const nameScale = c.textSize(nameAscii, 2).w <= tW && nameAscii.length <= max2 ? 2 : 1;
const nameStr = truncate(nameAscii, nameScale === 2 ? max2 : max1);
const nameH = 7 * nameScale;
/* Anchor the name to the top of the avatar (visually aligned
* with the picture) and let the bio flow downward. */
const blockTop = avY + 1;
c.drawText(tX, blockTop, nameStr, blk, nameScale);
c.drawText(tX + 1, blockTop, nameStr, blk, nameScale); // faux-bold
/* Flexible bio
* Wrap into however many lines fit between the name and the
* divider. Lines that don't fit get an ellipsis on the last
* surviving line, so the user can tell text was truncated.
* If the user has no bio at all we fall back to a deterministic
* filler line so the slot doesn't read as a render bug. */
{
const bioText = user.bio && user.bio.trim()
? ascii(user.bio).trim()
: pickFiller(user.login);
const bioTop = blockTop + nameH + 3;
const bioBudget = bandBot - bioTop;
/* Glyphs are 7 px tall - flush them with no inter-line gap so
* we squeeze the maximum number of full lines into the band.
* Descenders aren't a concern in a 5x7 bitmap font. */
const lineH = 7;
const maxLines = Math.max(0, Math.floor((bioBudget + 1) / lineH));
if (maxLines > 0) {
const lines = wrapText(bioText, max1, maxLines);
let by = bioTop;
for (const ln of lines) {
c.drawText(tX, by, ln, blk, 1);
by += lineH;
}
}
}
/* 3. Hairline divider
* Short of the side margins so it reads as a rule, not a
* frame extension. */
c.hline(padX + 4, dividerY, W - 2 * (padX + 4), blk);
/* 4. Stats panel: 3 equal columns
* Numbers in scale 2, labels in scale 1, vertical hairlines
* between columns. The STARS number gets accent ink so the
* eye lands on it first. */
const statsTop = dividerY + 2;
const statsBot = H - 2;
const colW = Math.floor((W - 2 * padX) / 3);
const stats: Array<{ num: string; label: string; accent: boolean }> = [
{ num: compact(stars ?? 0), label: "STARS", accent: true },
{ num: compact(user.followers), label: "FOLLOWERS", accent: false },
{ num: compact(user.public_repos), label: "REPOS", accent: false },
];
/* Number sits directly above the label with a 2-px gap, both
* pinned to the bottom of the band. This keeps the divider /
* portrait band tall while the stats hug the bottom edge. */
const lblY = statsBot - 8;
const numY = lblY - 14 - 2;
for (let i = 0; i < 3; i++) {
const s = stats[i];
const cx = padX + colW * i + (colW >> 1);
const ns = c.textSize(s.num, 2);
c.drawText(cx - (ns.w >> 1), numY, s.num, s.accent ? acc : blk, 2);
const ls = c.textSize(s.label, 1);
c.drawText(cx - (ls.w >> 1), lblY, s.label, blk, 1);
}
/* Column dividers: a couple of pixels in from the band edges
* so they don't kiss the outer frame. */
for (let i = 1; i < 3; i++) {
const dx = padX + colW * i;
c.vline(dx, statsTop + 2, statsBot - statsTop - 4, blk);
}
/* Outer 1-px frame
* Drawn last so it overlays the stripe corners cleanly. */
c.rect(0, 0, W, H, blk);
return c;
},
};
@@ -0,0 +1,392 @@
/*
* Identicon plugin - DEF CON / hardware-hacker badge aesthetic.
*
* The card is laid out like an actual electronics badge: a "chip" on the
* left holding the symmetric pixel-art identicon (with silkscreen border
* and orientation notch), a typographic stack on the right with the
* callsign in big type / a hex UID / a role tag, PCB-style pin headers
* along the top edge, and a binary "barcode" footer that's deterministic
* from the seed so two people with the same name get the same pattern -
* exactly like a real con badge. Accent ink (red/yellow on tri-colour
* tags) carries the role tag, chip notch, pin row and a few signature
* highlights so the card pops in a photo.
*
* Avatar sources:
* - "local" : the built-in symmetric 5x5/7x7/9x9 pixel grid.
* - DiceBear : pixel-art / bottts / lorelei / micah / adventurer /
* fun-emoji / shapes / pixel - fetched from the public
* DiceBear API as a small PNG, decoded with upng-js,
* and Floyd-Steinberg dithered into the chip area.
* Same callsign always picks the same face because
* the seed is the literal name.
*/
import { Canvas, Ink } from "../canvas";
import { Plugin, AccentMode } from "../plugin";
import { fetchImageGray, blitGrayDither } from "../image_util";
/* FNV-1a 32-bit. Used everywhere we need a deterministic seed. */
function hash32(s: string): number {
let h = 2166136261 >>> 0;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
}
return h >>> 0;
}
/* xorshift32 PRNG factory. */
function rng(seed: number) {
let r = seed || 0xCAFEBABE;
return () => {
r ^= r << 13; r >>>= 0;
r ^= r >>> 17;
r ^= r << 5; r >>>= 0;
return r;
};
}
/* Hacker-flavoured class tags. Pick deterministically from the seed
* so the same name always gets the same role - just like a real con
* where your callsign is the immutable part of your identity. */
const ROLES = [
"OPERATOR", "ANALYST", "PHREAK", "CRACKER",
"HACKER", "AGENT", "GHOST", "ROGUE",
"ARTIST", "WIZARD", "SCOUT", "CIPHER",
];
/* DiceBear style ids supported by this plugin. The empty string maps to
* the local generator. Order matters - it's the order shown in the FAP
* dropdown. */
const STYLES = [
"local", "pixel-art", "bottts", "lorelei",
"adventurer", "micah", "fun-emoji", "shapes",
];
/* DiceBear avatar URL builder. The actual fetch / decode / dither is
* done by the shared `image_util` helpers so every other image plugin
* (GitHub avatars, NASA APOD, etc.) shares the same pipeline. */
function dicebearUrl(style: string, seed: string, size: number): string {
return `https://api.dicebear.com/9.x/${encodeURIComponent(style)}` +
`/png?seed=${encodeURIComponent(seed)}` +
`&size=${size}` +
`&backgroundColor=ffffff`;
}
/* ─── Drawing helpers ─────────────────────────────────────────────── */
/** A dashed horizontal line - reads as a PCB trace. */
function dashedHLine(c: Canvas, x: number, y: number, w: number, dash: number, gap: number, ink: Ink) {
let xx = 0;
while (xx < w) {
const len = Math.min(dash, w - xx);
for (let i = 0; i < len; i++) c.setPixel(x + xx + i, y, ink);
xx += dash + gap;
}
}
/** Pin-header row: alternating filled/empty squares like an IC pin
* strip. Used at the top of the badge for hardware authenticity. */
function pinHeader(c: Canvas, x: number, y: number, count: number,
pin: number, gap: number, ink: Ink) {
for (let i = 0; i < count; i++) {
const px = x + i * (pin + gap);
if ((i & 1) === 0) {
// filled square
for (let dy = 0; dy < pin; dy++)
for (let dx = 0; dx < pin; dx++)
c.setPixel(px + dx, y + dy, ink);
} else {
// empty square (outline only)
c.rect(px, y, pin, pin, ink);
}
}
}
/** Chip silkscreen: rounded-corner rectangle with a notch on top
* (the universal "this is an IC" affordance). */
function chipFrame(c: Canvas, x: number, y: number, w: number, h: number,
notchInk: Ink, frameInk: Ink) {
// Outer frame
c.rect(x, y, w, h, frameInk);
// Inner shadow line for "engraved" feel
c.rect(x + 2, y + 2, w - 4, h - 4, frameInk);
// Top-centre orientation notch (semicircle approximation in 5 px)
const nx = x + (w >> 1) - 2;
const ny = y;
// Fill paper above the notch then re-draw with notch ink.
for (let i = 0; i < 5; i++) c.whitePixel(nx + i, ny);
for (let i = 0; i < 5; i++) c.whitePixel(nx + i, ny + 1);
// Notch arc
c.setPixel(nx + 1, ny, notchInk);
c.setPixel(nx + 2, ny, notchInk);
c.setPixel(nx + 3, ny, notchInk);
c.setPixel(nx, ny + 1, notchInk);
c.setPixel(nx + 4, ny + 1, notchInk);
c.setPixel(nx + 1, ny + 2, notchInk);
c.setPixel(nx + 2, ny + 2, notchInk);
c.setPixel(nx + 3, ny + 2, notchInk);
}
/** Crosshair / reticle - decorative element. */
function crosshair(c: Canvas, cx: number, cy: number, r: number, ink: Ink) {
c.line(cx - r, cy, cx + r, cy, ink);
c.line(cx, cy - r, cx, cy + r, ink);
// Outer tick marks
c.setPixel(cx - r - 2, cy, ink);
c.setPixel(cx + r + 2, cy, ink);
c.setPixel(cx, cy - r - 2, ink);
c.setPixel(cx, cy + r + 2, ink);
// Centre dot
c.setPixel(cx, cy, ink);
}
/** Binary "barcode" strip derived from a seed. Each bit becomes a
* filled or empty cell of the given width. Looks like a hardware
* serial barcode, reads as cyberpunk decoration. */
function binaryBar(c: Canvas, x: number, y: number, totalW: number, h: number,
seed: number, ink: Ink) {
const cellW = 2;
const cells = Math.floor(totalW / cellW);
let r = seed;
for (let i = 0; i < cells; i++) {
r ^= r << 13; r >>>= 0;
r ^= r >>> 17;
r ^= r << 5; r >>>= 0;
if ((r & 0xFF) > 110) {
for (let dy = 0; dy < h; dy++)
for (let dx = 0; dx < cellW; dx++)
c.setPixel(x + i * cellW + dx, y + dy, ink);
}
}
}
/* ─── Plugin ─────────────────────────────────────────────────────── */
export const identiconPlugin: Plugin = {
manifest: {
id: "identicon",
name: "Identicon",
description: "DEF CON-style hacker badge from a callsign",
accent_modes: 1 | 2 | 4,
params: [
{ key: "name", label: "Callsign", type: "string", default: "Dolphin" },
{ key: "style", label: "Style", type: "enum", default: "pixel-art",
options: STYLES },
/* These two only matter when style == "local" - the DiceBear
* styles render a pre-composed image and ignore them. */
{ key: "grid", label: "Grid", type: "enum", default: "7",
options: ["5", "7", "9"] },
{ key: "subStyle", label: "Local Pat.", type: "enum", default: "blocky",
options: ["blocky", "rings", "diagonal"] },
],
},
async render(params, W, H, accent: AccentMode) {
const name = ((params.name ?? "Dolphin").trim() || "Dolphin").toUpperCase();
const style = params.style ?? "pixel-art";
const grid = parseInt(params.grid ?? "7", 10);
const subStyle = params.subStyle ?? "blocky";
const seed = hash32(name);
const planes: 1 | 2 = accent === "none" ? 1 : 2;
const c = new Canvas(W, H, planes);
const acc: Ink = planes === 2 ? 1 : 0;
const blk: Ink = 0;
/* ── Geometry ──────────────────────────────────────────────── */
const margin = 4;
const headerH = 8; // top pin row + trace
const footerH = 12; // bottom barcode + tagline
/* ── Top pin header strip ──────────────────────────────────── */
{
const pinSize = 3;
const pinGap = 2;
const stride = pinSize + pinGap;
const count = Math.floor((W - margin * 2) / stride);
const px = margin + 1;
const py = margin;
pinHeader(c, px, py, count, pinSize, pinGap, acc);
}
/* PCB trace just under the pin row, with a gap in the middle for
* a small label tag. */
{
const traceY = margin + 8;
const tagText = `\u25C6 DEFCON 2026 \u25C6`;
const tagSize = c.textSize(tagText, 1);
const tagX = (W - tagSize.w) >> 1;
// Left dashed segment.
dashedHLine(c, margin + 4, traceY, tagX - margin - 8, 4, 2, blk);
// Right dashed segment.
const rStart = tagX + tagSize.w + 4;
dashedHLine(c, rStart, traceY, W - margin - 4 - rStart, 4, 2, blk);
// Tag label centred.
c.drawText(tagX, traceY - 3, tagText, blk, 1);
}
/* ── Identicon "chip" on the left ─────────────────────────── */
const bodyTop = margin + headerH + 8;
const bodyBot = H - footerH - margin;
const bodyH = bodyBot - bodyTop;
const chipSize = Math.min(bodyH, Math.floor(W * 0.42));
const chipX = margin + 2;
const chipY = bodyTop + ((bodyH - chipSize) >> 1);
chipFrame(c, chipX, chipY, chipSize, chipSize, acc, blk);
/* Inner area where the avatar lives - the chip frame eats 6 px on
* each side so the avatar isn't crowding the silkscreen. */
const inset = 6;
const innerW = chipSize - inset * 2;
const innerH = chipSize - inset * 2;
const innerX = chipX + inset;
const innerY = chipY + inset;
if (style === "local") {
/* Built-in symmetric pixel grid (zero-network). */
const cell = Math.floor(innerW / grid);
const used = cell * grid;
const ax = chipX + ((chipSize - used) >> 1);
const ay = chipY + ((chipSize - used) >> 1);
const half = Math.ceil(grid / 2);
const next = rng(seed);
const cells: boolean[][] = [];
for (let y = 0; y < grid; y++) {
cells.push([]);
for (let x = 0; x < half; x++) {
let on = (next() & 0xFF) > 110;
if (subStyle === "rings") {
const dx = x - (half - 1) / 2;
const dy = y - (grid - 1) / 2;
on = on !== ((Math.round(Math.sqrt(dx * dx + dy * dy)) & 1) === 0);
} else if (subStyle === "diagonal") {
on = on !== (((x + y) & 1) === 0);
}
cells[y].push(on);
}
}
for (let y = 0; y < grid; y++) {
for (let x = 0; x < grid; x++) {
const sx = x < half ? x : grid - 1 - x;
if (cells[y][sx]) {
c.fillRect(ax + x * cell, ay + y * cell, cell, cell, blk);
}
}
}
} else {
/* DiceBear-rendered avatar: fetch a small PNG (max(innerW, 96))
* for crisp dithering, then Floyd-Steinberg into the chip. We
* fall through to the local generator on any network failure
* so a flaky connection still produces a valid badge. */
const fetchSize = Math.max(96, innerW);
const dib = await fetchImageGray(dicebearUrl(style, name, fetchSize));
if (dib) {
blitGrayDither(c, innerX, innerY, innerW, innerH, dib, blk);
} else {
/* Soft fallback: a single accented "?" in the chip area so the
* user knows the avatar fetch failed but the rest of the
* badge is still useful. */
const q = "?";
const qs = c.textSize(q, 4);
c.drawText(innerX + ((innerW - qs.w) >> 1),
innerY + ((innerH - qs.h) >> 1),
q, acc, 4);
}
}
/* Tiny crosshair in the chip's bottom-right corner - reads as a
* registration/orientation mark, very PCB. */
crosshair(c, chipX + chipSize - 6, chipY + chipSize - 6, 2, acc);
/* ── Right column: callsign / UID / role tag ──────────────── */
const rightX = chipX + chipSize + 8;
const rightW = W - rightX - margin - 2;
/* CALLSIGN: pick the largest scale the name fits in. */
let callScale = 3;
while (callScale > 1 && c.textSize(name, callScale).w > rightW) callScale--;
const callY = bodyTop + 2;
c.drawText(rightX, callY, name.slice(0, 12), blk, callScale);
const callH = c.textSize(name, callScale).h;
/* Black underline below callsign - thick statement bar. */
{
const ulY = callY + callH + 2;
const ulW = Math.min(rightW - 4, c.textSize(name.slice(0, 12), callScale).w);
for (let i = 0; i < 2; i++) c.hline(rightX, ulY + i, ulW, blk);
}
/* UID line: "UID 0xA4F1B2C3" in mono. */
const uidY = callY + callH + 8;
const uidStr = "UID 0x" + (seed >>> 0).toString(16).toUpperCase().padStart(8, "0");
c.drawText(rightX, uidY, uidStr, blk, 1);
/* Role pill: "OPERATOR" etc. in solid accent block with white
* knockout text - matches the crypto badge styling. */
{
const role = ROLES[seed % ROLES.length];
const roleSize = c.textSize(role, 1);
const padX = 4;
const padY = 3;
const bw = roleSize.w + padX * 2;
const bh = roleSize.h + padY * 2;
const bx = rightX;
const by = uidY + roleSize.h + 6;
c.fillRect(bx, by, bw, bh, acc);
c.drawTextWhite(bx + padX, by + padY, role, 1);
}
/* "MEMBER SINCE 2026" small caps line, lower right. */
{
const labelY = bodyBot - 8;
const small = "MEMBER \u00B7 2026";
c.drawText(rightX, labelY, small, blk, 1);
}
/* ── Footer: binary barcode + tagline ─────────────────────── */
{
const fy = H - footerH - 1;
// Left & right ranger marks.
c.drawText(margin, fy + 4, "\u25A0\u25A0\u25A1\u25A0", blk, 1);
const tail = "\u25A0\u25A1\u25A0\u25A0";
const tailW = c.textSize(tail, 1).w;
c.drawText(W - margin - tailW, fy + 4, tail, blk, 1);
// Binary barcode strip in the middle.
const barX = margin + 28;
const barW = W - margin * 2 - 56;
binaryBar(c, barX, fy + 2, barW, 6, seed ^ 0xA5A5A5A5, blk);
}
/* ── Outer card border + corner brackets in accent ────────── */
c.rect(0, 0, W, H, blk);
/* Heavy corner brackets in accent give the card the "viewfinder"
* frame look that translates so well to a phone photo. */
const cornerLen = 8;
// top-left
for (let i = 0; i < 2; i++) {
c.hline(0, i, cornerLen, acc);
c.vline(i, 0, cornerLen, acc);
}
// top-right
for (let i = 0; i < 2; i++) {
c.hline(W - cornerLen, i, cornerLen, acc);
c.vline(W - 1 - i, 0, cornerLen, acc);
}
// bottom-left
for (let i = 0; i < 2; i++) {
c.hline(0, H - 1 - i, cornerLen, acc);
c.vline(i, H - cornerLen, cornerLen, acc);
}
// bottom-right
for (let i = 0; i < 2; i++) {
c.hline(W - cornerLen, H - 1 - i, cornerLen, acc);
c.vline(W - 1 - i, H - cornerLen, cornerLen, acc);
}
return c;
},
};
@@ -0,0 +1,180 @@
/*
* Weather plugin.
*
* Renders a clean, minimalist weather card:
*
*
* AMSTERDAM NL
*
* 12°C
* partly cloudy
*
* Mon 14° / 8° Tue 11° / 6°
*
*
* Procedural icons (sun, cloud, rain, snow, storm) drawn with the canvas
* primitives - they scale cleanly to any tag size and look distinctive
* even at the smallest 152x152 panels.
*
* Data source: wttr.in (free, no key, returns JSON when ?format=j1).
*/
import { Canvas, Ink } from "../canvas";
import { Plugin, AccentMode } from "../plugin";
type Sky = "sun" | "cloud" | "rain" | "snow" | "storm" | "fog";
function classify(code: number): Sky {
// wttr WWO weatherCode mapping to a small icon vocabulary.
if ([113].includes(code)) return "sun";
if ([116, 119, 122].includes(code)) return "cloud";
if ([143, 248, 260].includes(code)) return "fog";
if ([200, 386, 389, 392, 395].includes(code)) return "storm";
if ([179, 227, 230, 320, 323, 326, 329, 332, 335, 338, 350, 368, 371, 374, 377].includes(code))
return "snow";
return "rain";
}
function drawIcon(c: Canvas, sky: Sky, cx: number, cy: number, r: number, accent: Ink): void {
switch (sky) {
case "sun": {
// Filled disc + 8 rays in accent ink.
for (let y = -r; y <= r; y++)
for (let x = -r; x <= r; x++)
if (x * x + y * y <= (r - 1) * (r - 1)) c.setPixel(cx + x, cy + y, accent);
const rr = r + 2, R = r + r;
c.line(cx - R, cy, cx - rr, cy, accent);
c.line(cx + rr, cy, cx + R, cy, accent);
c.line(cx, cy - R, cx, cy - rr, accent);
c.line(cx, cy + rr, cx, cy + R, accent);
c.line(cx - R, cy - R, cx - rr, cy - rr, accent);
c.line(cx + rr, cy - rr, cx + R, cy - R, accent);
c.line(cx - R, cy + R, cx - rr, cy + rr, accent);
c.line(cx + rr, cy + rr, cx + R, cy + R, accent);
break;
}
case "cloud": {
// Three overlapping discs forming a cloud silhouette.
const blob = (ox: number, oy: number, br: number) => {
for (let y = -br; y <= br; y++)
for (let x = -br; x <= br; x++)
if (x * x + y * y <= br * br) c.setPixel(cx + ox + x, cy + oy + y, 0);
};
blob(-r, 2, Math.floor(r * 0.6));
blob(0, -2, Math.floor(r * 0.7));
blob(r - 2, 2, Math.floor(r * 0.6));
c.hline(cx - r, cy + Math.floor(r * 0.6), 2 * r, 0);
break;
}
case "rain":
drawIcon(c, "cloud", cx, cy, r, 0);
for (let i = -r; i <= r; i += 4) {
c.line(cx + i, cy + r + 2, cx + i - 2, cy + r + 6, accent);
}
break;
case "snow":
drawIcon(c, "cloud", cx, cy, r, 0);
for (let i = -r; i <= r; i += 5) {
const yy = cy + r + 4;
c.setPixel(cx + i, yy, accent);
c.setPixel(cx + i - 1, yy + 1, accent);
c.setPixel(cx + i + 1, yy + 1, accent);
c.setPixel(cx + i, yy + 2, accent);
}
break;
case "storm":
drawIcon(c, "cloud", cx, cy, r, 0);
// Lightning bolt
const bx = cx, by = cy + r;
c.line(bx, by, bx + 3, by + 4, accent);
c.line(bx + 3, by + 4, bx - 1, by + 5, accent);
c.line(bx - 1, by + 5, bx + 3, by + 9, accent);
break;
case "fog":
for (let i = 0; i < 4; i++) {
c.hline(cx - r + (i % 2) * 2, cy - r + i * 4, 2 * r - (i % 2) * 4, 0);
}
break;
}
}
async function fetchWeather(loc: string): Promise<any> {
const url = `https://wttr.in/${encodeURIComponent(loc)}?format=j1`;
const r = await fetch(url, { headers: { "User-Agent": "TagTinker/1.0" } });
if (!r.ok) throw new Error(`wttr ${r.status}`);
return await r.json();
}
export const weatherPlugin: Plugin = {
manifest: {
id: "weather",
name: "Weather",
description: "Live weather card with forecast",
accent_modes: 1 | 2 | 4,
params: [
{ key: "location", label: "Location", type: "string", default: "Paris" },
{ key: "units", label: "Units", type: "enum", default: "C", options: ["C", "F"] },
],
},
async render(params, W, H, accent: AccentMode) {
const loc = (params.location ?? "Paris").trim() || "Paris";
/* Defensive: only accept C or F. The Flipper used to (briefly) leak
* stale param values across plugins, which once produced "20°USD". */
const rawUnits = (params.units ?? "C").toUpperCase();
const units = rawUnits === "F" ? "F" : "C";
const data = await fetchWeather(loc);
const cur = data.current_condition?.[0] ?? {};
const code = parseInt(cur.weatherCode ?? "113", 10);
const sky = classify(code);
const tempC = parseFloat(cur.temp_C ?? "0");
const tempF = parseFloat(cur.temp_F ?? "32");
const desc = (cur.weatherDesc?.[0]?.value ?? "").toLowerCase();
const area = data.nearest_area?.[0];
const city = (area?.areaName?.[0]?.value ?? loc).toUpperCase();
const country = area?.country?.[0]?.value ?? "";
const planes: 1 | 2 = accent === "none" ? 1 : 2;
const c = new Canvas(W, H, planes);
const accentInk: Ink = planes === 2 ? 1 : 0;
const margin = W < 200 ? 4 : 8;
// Header: city + country on right.
c.drawText(margin, margin, city, 0, 1);
c.drawTextRight(W - margin, margin, country.toUpperCase(), 0, 1);
c.hline(margin, margin + 9, W - 2 * margin, 0);
// Big icon + temperature
const iconR = Math.min(20, Math.floor(H / 6));
const ix = margin + iconR + 4;
const iy = margin + 18 + iconR;
drawIcon(c, sky, ix, iy, iconR, accentInk);
const t = `${units === "F" ? Math.round(tempF) : Math.round(tempC)}\xB0${units}`;
let scale = W >= 260 ? 3 : 2;
while (scale > 1 && c.textSize(t, scale).w > W - ix - iconR - margin * 2) scale--;
c.drawText(ix + iconR + 8, iy - Math.floor(c.textSize(t, scale).h / 2), t, 0, scale);
// Description in italics-ish (just plain, but a row under).
c.drawText(margin, iy + iconR + 6, desc, 0, 1);
// 2-day forecast.
const fcs = data.weather?.slice(0, 2) ?? [];
if (fcs.length > 0) {
const baseY = H - margin - 9;
const cellW = Math.floor((W - 2 * margin) / fcs.length);
for (let i = 0; i < fcs.length; i++) {
const f = fcs[i];
const lo = units === "F" ? f.mintempF : f.mintempC;
const hi = units === "F" ? f.maxtempF : f.maxtempC;
const dn = f.date ? new Date(f.date).toLocaleDateString("en-US", { weekday: "short" }) : "";
const txt = `${dn} ${hi}\xB0/${lo}\xB0`;
c.drawText(margin + i * cellW, baseY, txt, 0, 1);
}
}
return c;
},
};
@@ -0,0 +1,35 @@
/* Minimal type shims for the CommonJS image libraries we use in the
* worker bundle. These libs have no official @types packages, and we
* only touch a tiny slice of each surface, so a hand-written shim is
* cheaper than wrestling with `allowJs`. */
declare module "jpeg-js" {
interface DecodedJpeg {
width: number;
height: number;
data: Uint8Array | Buffer;
}
interface JpegJs {
decode(buf: ArrayBuffer | Uint8Array,
opts?: { useTArray?: boolean; maxMemoryUsageInMB?: number; formatAsRGBA?: boolean })
: DecodedJpeg;
encode(rawImageData: { data: Uint8Array; width: number; height: number },
quality?: number): { data: Uint8Array; width: number; height: number };
}
const jpegJs: JpegJs;
export default jpegJs;
}
declare module "upng-js" {
interface DecodedPng {
width: number;
height: number;
depth: number;
ctype: number;
data: Uint8Array;
tabs: Record<string, unknown>;
frames: unknown[];
}
export function decode(buf: ArrayBuffer | Uint8Array): DecodedPng;
export function toRGBA8(out: DecodedPng): ArrayBuffer[];
}
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"resolveJsonModule": true
},
"include": ["src/**/*.ts"]
}
@@ -0,0 +1,7 @@
name = "tagtinker"
main = "src/index.ts"
compatibility_date = "2024-09-23"
# `npm run deploy` will publish to <name>.<account-subdomain>.workers.dev.
# Once deployed, point the ESP firmware at the resulting URL via
# the FAP "WiFi Setup -> Server URL" field.
@@ -0,0 +1,7 @@
# TagTinker WiFi firmware - ESP-IDF top-level CMake.
cmake_minimum_required(VERSION 3.16)
set(EXTRA_COMPONENT_DIRS shared)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(tagtinker_wifi)
@@ -0,0 +1,21 @@
# TagTinker WiFi cloud-renderer - thin firmware. All drawing and API
# integration lives in cloud-plugins/ (Cloudflare Worker); this binary
# is just a UART <-> HTTPS bridge.
idf_component_register(
SRCS
"main.c"
"wifi_link.c"
"wifi_net.c"
"cloud_client.c"
INCLUDE_DIRS "."
REQUIRES
nvs_flash
esp_wifi
esp_netif
esp_event
esp_http_client
esp-tls
driver
json
shared
)
@@ -0,0 +1,230 @@
/*
* Cloud client - implementation.
*
* Uses esp_http_client. URL escapes parameters before they're appended to
* the query string. The render path streams the response directly via
* the HTTP_EVENT_ON_DATA callback to avoid buffering the (potentially
* 50+ KB) framebuffer in memory.
*/
#include "cloud_client.h"
#include "esp_http_client.h"
#include "esp_crt_bundle.h"
#include "esp_log.h"
#include "nvs.h"
#include "nvs_flash.h"
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static const char* TAG = "cloud";
static const char* NVS_NS = "tt_cloud";
static char s_base_url[128] = TT_CLOUD_DEFAULT_URL;
const char* cloud_client_url(void) { return s_base_url; }
void cloud_client_set_url(const char* url) {
if(!url || !*url) return;
strncpy(s_base_url, url, sizeof(s_base_url) - 1);
s_base_url[sizeof(s_base_url) - 1] = 0;
nvs_handle_t h;
if(nvs_open(NVS_NS, NVS_READWRITE, &h) == ESP_OK) {
nvs_set_str(h, "url", s_base_url);
nvs_commit(h);
nvs_close(h);
}
}
void cloud_client_load(void) {
nvs_handle_t h;
if(nvs_open(NVS_NS, NVS_READONLY, &h) != ESP_OK) return;
size_t l = sizeof(s_base_url);
if(nvs_get_str(h, "url", s_base_url, &l) != ESP_OK) {
strncpy(s_base_url, TT_CLOUD_DEFAULT_URL, sizeof(s_base_url) - 1);
}
nvs_close(h);
}
/* ---- Tiny URL builder --------------------------------------------------- */
static int hexv(uint8_t b) { return b < 10 ? '0' + b : 'a' + (b - 10); }
static void url_append_escaped(char* dst, size_t cap, size_t* pos, const char* s) {
while(*s && *pos + 4 < cap) {
unsigned char c = (unsigned char)*s++;
bool safe = (c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') ||
(c >= 'a' && c <= 'z') || c == '-' || c == '_' || c == '.';
if(safe) {
dst[(*pos)++] = (char)c;
} else {
dst[(*pos)++] = '%';
dst[(*pos)++] = (char)hexv(c >> 4);
dst[(*pos)++] = (char)hexv(c & 0xF);
}
}
dst[*pos] = 0;
}
/* ---- /plugins ----------------------------------------------------------- */
typedef struct {
char* buf;
size_t cap;
size_t len;
} BodyBuf;
static esp_err_t body_evt(esp_http_client_event_t* e) {
BodyBuf* b = e->user_data;
if(e->event_id != HTTP_EVENT_ON_DATA) return ESP_OK;
size_t take = e->data_len;
if(b->len + take + 1 > b->cap) take = (b->cap > b->len + 1) ? (b->cap - b->len - 1) : 0;
if(take == 0) return ESP_OK;
memcpy(b->buf + b->len, e->data, take);
b->len += take;
b->buf[b->len] = 0;
return ESP_OK;
}
char* cloud_client_fetch_plugins_json(size_t* out_len) {
char url[256];
snprintf(url, sizeof(url), "%s/plugins", s_base_url);
BodyBuf b = { .buf = malloc(8192), .cap = 8192, .len = 0 };
if(!b.buf) return NULL;
b.buf[0] = 0;
esp_http_client_config_t cfg = {
.url = url,
.event_handler = body_evt,
.user_data = &b,
.timeout_ms = 20000,
.crt_bundle_attach = esp_crt_bundle_attach,
};
esp_http_client_handle_t c = esp_http_client_init(&cfg);
if(!c) { free(b.buf); return NULL; }
esp_http_client_set_header(c, "User-Agent", "TagTinker-WiFi/2.0");
esp_err_t r = esp_http_client_perform(c);
int code = esp_http_client_get_status_code(c);
esp_http_client_cleanup(c);
if(r != ESP_OK || code != 200) {
ESP_LOGW(TAG, "plugins: err=%d code=%d", r, code);
free(b.buf);
return NULL;
}
if(out_len) *out_len = b.len;
return b.buf;
}
/* ---- /render -- streaming -------------------------------------------- */
bool cloud_client_render(
const char* plugin_id,
uint16_t target_w, uint16_t target_h,
uint8_t accent,
const char* const* keys,
const char* const* values,
uint8_t n_params,
uint16_t* out_w, uint16_t* out_h,
uint8_t* out_planes, uint16_t* out_row_stride,
tt_cloud_chunk_cb chunk_cb, void* user,
char err_msg[64]) {
/* Build URL: <base>/render/<id>?w=<>&h=<>&accent=<>&<k>=<v>... */
char url[768];
size_t pos = 0;
int wrote = snprintf(url, sizeof(url), "%s/render/", s_base_url);
if(wrote < 0 || (size_t)wrote >= sizeof(url)) {
if(err_msg) snprintf(err_msg, 64, "url too long");
return false;
}
pos = (size_t)wrote;
url_append_escaped(url, sizeof(url), &pos, plugin_id);
int wrote2 = snprintf(url + pos, sizeof(url) - pos,
"?w=%u&h=%u&accent=%s",
(unsigned)target_w, (unsigned)target_h,
accent == 1 ? "red" : accent == 2 ? "yellow" : "none");
if(wrote2 < 0) {
if(err_msg) snprintf(err_msg, 64, "url build failed");
return false;
}
pos += (size_t)wrote2;
for(uint8_t i = 0; i < n_params; i++) {
if(pos + 4 >= sizeof(url)) break;
url[pos++] = '&';
url_append_escaped(url, sizeof(url), &pos, keys[i]);
url[pos++] = '=';
url_append_escaped(url, sizeof(url), &pos, values[i]);
}
url[pos] = 0;
/* We use the streaming HTTP API so we can read the 8-byte header
* first (and surface w/h/planes to the caller before forwarding any
* payload), then loop reading body bytes as they arrive. This lets
* the caller emit RESULT_BEGIN at exactly the right moment. */
esp_http_client_config_t cfg = {
.url = url,
.timeout_ms = 25000,
.crt_bundle_attach = esp_crt_bundle_attach,
.buffer_size = 1024,
};
esp_http_client_handle_t c = esp_http_client_init(&cfg);
if(!c) { if(err_msg) snprintf(err_msg, 64, "client init"); return false; }
esp_http_client_set_header(c, "User-Agent", "TagTinker-WiFi/2.0");
esp_err_t r = esp_http_client_open(c, 0);
if(r != ESP_OK) {
esp_http_client_cleanup(c);
if(err_msg) snprintf(err_msg, 64, "open %d", r);
return false;
}
int64_t total = esp_http_client_fetch_headers(c);
int code = esp_http_client_get_status_code(c);
if(code != 200) {
esp_http_client_close(c);
esp_http_client_cleanup(c);
if(err_msg) snprintf(err_msg, 64, "http %d", code);
return false;
}
(void)total;
/* Read the 8-byte header. */
uint8_t hdr[8]; size_t got = 0;
while(got < 8) {
int n = esp_http_client_read(c, (char*)hdr + got, 8 - got);
if(n <= 0) break;
got += (size_t)n;
}
if(got < 8) {
esp_http_client_close(c); esp_http_client_cleanup(c);
if(err_msg) snprintf(err_msg, 64, "short header");
return false;
}
uint16_t W = (uint16_t)hdr[0] | ((uint16_t)hdr[1] << 8);
uint16_t H = (uint16_t)hdr[2] | ((uint16_t)hdr[3] << 8);
uint8_t P = hdr[4];
uint16_t RS = (uint16_t)hdr[6] | ((uint16_t)hdr[7] << 8);
if(out_w) *out_w = W;
if(out_h) *out_h = H;
if(out_planes) *out_planes = P;
if(out_row_stride) *out_row_stride = RS;
/* Stream the rest in <= 512-byte chunks. */
uint8_t chunk[512];
while(true) {
int n = esp_http_client_read(c, (char*)chunk, sizeof(chunk));
if(n < 0) break;
if(n == 0) {
if(esp_http_client_is_complete_data_received(c)) break;
continue;
}
if(chunk_cb) chunk_cb(chunk, (uint16_t)n, user);
}
esp_http_client_close(c);
esp_http_client_cleanup(c);
return true;
}
@@ -0,0 +1,54 @@
/*
* Cloud plugin client.
*
* Talks to the TagTinker Cloudflare Worker that hosts plugin manifests
* and renders. Default URL is hard-coded below; override at runtime by
* calling cloud_client_set_url() (the value is persisted in NVS).
*
* GET <base>/plugins -> JSON manifest list
* GET <base>/render/<id>?...-> binary framebuffer (see worker docs)
*
* The framebuffer header (8 bytes, little-endian) is:
* uint16 width, uint16 height, uint8 planes, uint8 reserved,
* uint16 row_stride
* followed by `row_stride * height * planes` bytes of pixel data.
*/
#ifndef TT_CLOUD_CLIENT_H
#define TT_CLOUD_CLIENT_H
#include <stdbool.h>
#include <stdint.h>
#include <stddef.h>
#define TT_CLOUD_DEFAULT_URL "https://tagtinker.jhackerr.workers.dev"
/* Persisted base URL (no trailing slash). */
const char* cloud_client_url(void);
void cloud_client_set_url(const char* url);
void cloud_client_load(void); /* call once after nvs_flash_init */
/* Forwarded JSON for /plugins. Caller frees with free(). NULL on failure. */
char* cloud_client_fetch_plugins_json(size_t* out_len);
/* Streaming /render call.
*
* Returns true on 200 OK. The header (width, height, planes, row_stride)
* is filled in before any chunk_cb is invoked. chunk_cb is then called
* one or more times with consecutive plane bytes (chunk_len <= 1024).
* Plane 0 is delivered first, then plane 1 (if planes==2). */
typedef void (*tt_cloud_chunk_cb)(const uint8_t* data, uint16_t len, void* user);
bool cloud_client_render(
const char* plugin_id,
uint16_t target_w, uint16_t target_h,
uint8_t accent, /* 0=none, 1=red, 2=yellow */
const char* const* keys,
const char* const* values,
uint8_t n_params,
/* Output header: */
uint16_t* out_w, uint16_t* out_h,
uint8_t* out_planes, uint16_t* out_row_stride,
tt_cloud_chunk_cb chunk_cb, void* user,
char err_msg[64]);
#endif /* TT_CLOUD_CLIENT_H */
@@ -0,0 +1,354 @@
/*
* TagTinker WiFi firmware - thin cloud-renderer.
*
* The ESP no longer carries plugins, fonts, drawing primitives or HTTP
* APIs for individual data sources. Instead it acts as a small bridge:
*
* Flipper LIST_PLUGINS -> GET <cloud>/plugins -> forward N x PLUGIN
* Flipper RUN_PLUGIN -> GET <cloud>/render/<id>?...-> forward as
* RESULT_BEGIN + N x CHUNK + RESULT_END.
*
* Plugins live in the Cloudflare Worker at the URL printed during
* `wrangler deploy`; updating plugins doesn't require re-flashing.
*
* A minimal cJSON-based parser converts the worker's /plugins JSON into
* the framed-protocol PLUGIN frames the FAP already understands, so the
* Flipper-side code is unchanged.
*/
#include "tt_wifi_proto.h"
#include "wifi_link.h"
#include "wifi_net.h"
#include "cloud_client.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "cJSON.h"
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
static const char* TAG = "main";
/* ---- Encoding helpers --------------------------------------------------- */
typedef struct { uint8_t* p; uint16_t cap; uint16_t len; } Wb;
static void wb_init(Wb* b, uint8_t* buf, uint16_t cap) { b->p = buf; b->cap = cap; b->len = 0; }
static void wb_u8 (Wb* b, uint8_t v) { if(b->len < b->cap) b->p[b->len++] = v; }
static void wb_u16(Wb* b, uint16_t v) { wb_u8(b, v & 0xFF); wb_u8(b, v >> 8); }
static void wb_u32(Wb* b, uint32_t v) { wb_u16(b, v); wb_u16(b, v >> 16); }
static void wb_i32(Wb* b, int32_t v) { wb_u32(b, (uint32_t)v); }
static void wb_zstr(Wb* b, const char* s) {
if(!s) s = "";
size_t l = strlen(s);
if(l > 255) l = 255;
wb_u8(b, (uint8_t)l);
for(size_t i = 0; i < l && b->len < b->cap; i++) b->p[b->len++] = (uint8_t)s[i];
}
/* ---- Decoding helpers --------------------------------------------------- */
typedef struct { const uint8_t* p; uint16_t len; uint16_t pos; } Rb;
static void rb_init(Rb* r, const uint8_t* p, uint16_t len) { r->p = p; r->len = len; r->pos = 0; }
static bool rb_u8 (Rb* r, uint8_t* v) { if(r->pos + 1 > r->len) return false; *v = r->p[r->pos++]; return true; }
static bool rb_u16(Rb* r, uint16_t* v) { uint8_t a,b; if(!rb_u8(r,&a)||!rb_u8(r,&b)) return false; *v = (uint16_t)a | ((uint16_t)b << 8); return true; }
static bool rb_zstr(Rb* r, char* out, size_t cap) {
uint8_t l; if(!rb_u8(r, &l)) return false;
if(r->pos + l > r->len) return false;
size_t n = (l < cap - 1) ? l : cap - 1;
memcpy(out, r->p + r->pos, n);
out[n] = 0;
r->pos += l;
return true;
}
/* ---- HELLO / STATUS ---------------------------------------------------- */
static void send_hello(void) {
uint8_t buf[80]; Wb w; wb_init(&w, buf, sizeof(buf));
wb_u16(&w, 0x0200); /* fw version 2.0 (cloud) */
wb_u32(&w, esp_get_free_heap_size());
wb_zstr(&w, "TagTinker WiFi");
wifi_link_send(TT_FRAME_HELLO, buf, w.len);
}
static void send_wifi_status(void) {
uint8_t buf[80]; Wb w; wb_init(&w, buf, sizeof(buf));
wb_u8(&w, wifi_net_state());
wb_u8(&w, (uint8_t)(int8_t)wifi_net_rssi());
wb_zstr(&w, wifi_net_ssid());
wb_zstr(&w, wifi_net_ip());
wifi_link_send(TT_FRAME_WIFI_STATUS, buf, w.len);
}
/* ---- Cached plugins JSON --------------------------------------------- */
/* Filled by handle_list_plugins() and reused by handle_run_plugin() so we
* don't have to do a fresh TLS handshake just to translate plugin index
* back into id - critical on the S2 where we can barely fit one TLS
* session at a time. */
static cJSON* s_cached_root = NULL;
static const cJSON* s_cached_arr = NULL;
static void cached_set(cJSON* root) {
if(s_cached_root) { cJSON_Delete(s_cached_root); }
s_cached_root = root;
s_cached_arr = root ? cJSON_GetObjectItemCaseSensitive(root, "plugins") : NULL;
}
/* ---- /plugins JSON -> PLUGIN frames ----------------------------------- */
/* type tags from the worker -> wire type IDs the FAP expects. */
static uint8_t param_type_from(const char* t) {
if(!t) return 0;
if(strcmp(t, "string") == 0) return 0;
if(strcmp(t, "int") == 0) return 1;
if(strcmp(t, "enum") == 0) return 2;
if(strcmp(t, "bool") == 0) return 3;
return 0;
}
static void emit_plugin_from_json(int idx, const cJSON* p) {
uint8_t buf[TT_FRAME_MAX_PAYLOAD]; Wb w; wb_init(&w, buf, sizeof(buf));
wb_u8(&w, (uint8_t)idx);
const cJSON* id = cJSON_GetObjectItemCaseSensitive(p, "id");
const cJSON* name = cJSON_GetObjectItemCaseSensitive(p, "name");
const cJSON* desc = cJSON_GetObjectItemCaseSensitive(p, "description");
const cJSON* acc = cJSON_GetObjectItemCaseSensitive(p, "accent_modes");
const cJSON* params = cJSON_GetObjectItemCaseSensitive(p, "params");
wb_zstr(&w, cJSON_IsString(id) ? id->valuestring : "");
wb_zstr(&w, cJSON_IsString(name) ? name->valuestring : "");
wb_zstr(&w, cJSON_IsString(desc) ? desc->valuestring : "");
wb_u8 (&w, (uint8_t)(cJSON_IsNumber(acc) ? acc->valueint : 1));
int pc = cJSON_IsArray(params) ? cJSON_GetArraySize(params) : 0;
if(pc > 6) pc = 6;
wb_u8(&w, (uint8_t)pc);
for(int i = 0; i < pc; i++) {
const cJSON* sp = cJSON_GetArrayItem(params, i);
const cJSON* k = cJSON_GetObjectItemCaseSensitive(sp, "key");
const cJSON* l = cJSON_GetObjectItemCaseSensitive(sp, "label");
const cJSON* tt = cJSON_GetObjectItemCaseSensitive(sp, "type");
const cJSON* dv = cJSON_GetObjectItemCaseSensitive(sp, "default");
const cJSON* opts = cJSON_GetObjectItemCaseSensitive(sp, "options");
const cJSON* mn = cJSON_GetObjectItemCaseSensitive(sp, "min");
const cJSON* mx = cJSON_GetObjectItemCaseSensitive(sp, "max");
wb_zstr(&w, cJSON_IsString(k) ? k->valuestring : "");
wb_zstr(&w, cJSON_IsString(l) ? l->valuestring : "");
uint8_t pt = param_type_from(cJSON_IsString(tt) ? tt->valuestring : "string");
wb_u8(&w, pt);
wb_zstr(&w, cJSON_IsString(dv) ? dv->valuestring : "");
if(pt == 2) {
int oc = cJSON_IsArray(opts) ? cJSON_GetArraySize(opts) : 0;
if(oc > 8) oc = 8;
wb_u8(&w, (uint8_t)oc);
for(int j = 0; j < oc; j++) {
const cJSON* o = cJSON_GetArrayItem(opts, j);
wb_zstr(&w, cJSON_IsString(o) ? o->valuestring : "");
}
} else if(pt == 1) {
wb_i32(&w, cJSON_IsNumber(mn) ? mn->valueint : 0);
wb_i32(&w, cJSON_IsNumber(mx) ? mx->valueint : 100);
}
}
wifi_link_send(TT_FRAME_PLUGIN, buf, w.len);
}
static void handle_list_plugins(void) {
if(!wifi_net_wait_connected(8000)) {
wifi_link_send_error("WiFi not connected");
wifi_link_send(TT_FRAME_PLUGINS_END, NULL, 0);
return;
}
size_t n = 0;
char* body = cloud_client_fetch_plugins_json(&n);
if(!body) {
wifi_link_send_error("plugin fetch failed");
wifi_link_send(TT_FRAME_PLUGINS_END, NULL, 0);
return;
}
cJSON* root = cJSON_Parse(body);
free(body);
if(!root) {
wifi_link_send_error("plugin JSON parse failed");
wifi_link_send(TT_FRAME_PLUGINS_END, NULL, 0);
return;
}
cached_set(root);
int total = cJSON_IsArray(s_cached_arr) ? cJSON_GetArraySize(s_cached_arr) : 0;
for(int i = 0; i < total && i < 16; i++) {
emit_plugin_from_json(i, cJSON_GetArrayItem(s_cached_arr, i));
}
wifi_link_send(TT_FRAME_PLUGINS_END, NULL, 0);
}
/* ---- /render -- plugin run -------------------------------------------- */
/* Shared state for the render flow: cloud_client fills the dims first,
* then drives our chunk_cb which emits RESULT_BEGIN on its first call,
* then RESULT_CHUNKs, then RESULT_END after the call returns. */
typedef struct {
bool begin_sent;
uint16_t* out_w;
uint16_t* out_h;
uint8_t* out_planes;
uint16_t* out_rs;
uint32_t total_bytes;
uint32_t recv_bytes;
uint8_t last_pct_emitted;
} RenderFwd;
static void render_chunk_cb(const uint8_t* data, uint16_t len, void* user) {
RenderFwd* f = user;
if(!f->begin_sent) {
uint8_t hdr[16]; uint16_t off = 0;
uint16_t w = *f->out_w, h = *f->out_h;
uint8_t p = *f->out_planes;
uint16_t rs = *f->out_rs;
hdr[off++] = (uint8_t)(w & 0xFF); hdr[off++] = (uint8_t)(w >> 8);
hdr[off++] = (uint8_t)(h & 0xFF); hdr[off++] = (uint8_t)(h >> 8);
hdr[off++] = p;
uint32_t total = (uint32_t)rs * h * p;
hdr[off++] = (uint8_t)(total & 0xFF);
hdr[off++] = (uint8_t)((total >> 8) & 0xFF);
hdr[off++] = (uint8_t)((total >> 16) & 0xFF);
hdr[off++] = (uint8_t)((total >> 24) & 0xFF);
wifi_link_send(TT_FRAME_RESULT_BEGIN, hdr, off);
f->begin_sent = true;
f->total_bytes = total;
f->recv_bytes = 0;
f->last_pct_emitted = 50;
wifi_link_send_progress(50, "Receiving image");
}
while(len > 0) {
uint16_t take = len > 512 ? 512 : len;
wifi_link_send(TT_FRAME_RESULT_CHUNK, data, take);
data += take; len -= take;
f->recv_bytes += take;
}
/* 50..95% during streaming. Emit at most one progress frame per 10%
* boundary - sending one after every chunk doubled the frame count
* and starved the Flipper's UART RX during the burst, which was
* dropping tail bytes (see tagtinker_wifi.c history). */
if(f->total_bytes) {
uint32_t pct = 50 + (f->recv_bytes * 45) / f->total_bytes;
if(pct > 95) pct = 95;
if(pct >= f->last_pct_emitted + 10 || (pct >= 95 && f->last_pct_emitted < 95)) {
wifi_link_send_progress((uint8_t)pct, "Receiving image");
f->last_pct_emitted = (uint8_t)pct;
}
}
}
static void handle_run_plugin(const uint8_t* payload, uint16_t len) {
Rb r; rb_init(&r, payload, len);
char id[32];
uint8_t accent = 0, n_params = 0;
uint16_t target_w = 0, target_h = 0;
uint8_t plugin_idx = 0;
if(!rb_u8(&r, &plugin_idx)) { wifi_link_send_error("bad RUN frame"); return; }
if(!rb_u16(&r, &target_w)) { wifi_link_send_error("bad RUN frame"); return; }
if(!rb_u16(&r, &target_h)) { wifi_link_send_error("bad RUN frame"); return; }
if(!rb_u8(&r, &accent)) { wifi_link_send_error("bad RUN frame"); return; }
if(!rb_u8(&r, &n_params)) { wifi_link_send_error("bad RUN frame"); return; }
/* Use the manifest cached during the most recent /plugins call - this
* avoids a second TLS handshake and the associated memory pressure. */
if(!s_cached_arr) {
wifi_link_send_error("no plugin cache (refresh first)"); return;
}
const cJSON* p = cJSON_GetArrayItem(s_cached_arr, plugin_idx);
const cJSON* idj = p ? cJSON_GetObjectItemCaseSensitive(p, "id") : NULL;
if(!cJSON_IsString(idj)) {
wifi_link_send_error("bad plugin index"); return;
}
strncpy(id, idj->valuestring, sizeof(id) - 1);
id[sizeof(id) - 1] = 0;
/* Read params into parallel arrays for cloud_client_render. */
static char keys[6][32];
static char vals[6][96];
const char* k_ptrs[6];
const char* v_ptrs[6];
if(n_params > 6) n_params = 6;
for(uint8_t i = 0; i < n_params; i++) {
if(!rb_zstr(&r, keys[i], sizeof(keys[i])) ||
!rb_zstr(&r, vals[i], sizeof(vals[i]))) {
wifi_link_send_error("bad param"); return;
}
k_ptrs[i] = keys[i];
v_ptrs[i] = vals[i];
}
if(!wifi_net_wait_connected(8000)) {
wifi_link_send_error("WiFi not connected"); return;
}
wifi_link_send_progress(15, "Connecting to cloud");
uint16_t rw = 0, rh = 0, rs = 0; uint8_t rp = 0;
RenderFwd fwd = {
.begin_sent = false,
.out_w = &rw, .out_h = &rh, .out_planes = &rp, .out_rs = &rs,
};
char err[64] = {0};
bool ok = cloud_client_render(
id, target_w, target_h, accent,
k_ptrs, v_ptrs, n_params,
&rw, &rh, &rp, &rs,
render_chunk_cb, &fwd, err);
if(!ok) {
wifi_link_send_error(err[0] ? err : "render failed");
return;
}
if(!fwd.begin_sent) {
wifi_link_send_error("empty body");
return;
}
wifi_link_send(TT_FRAME_RESULT_END, NULL, 0);
wifi_link_send_progress(100, "Done");
}
/* ---- WIFI_SET / WIFI_FORGET ------------------------------------------- */
static void handle_wifi_set(const uint8_t* payload, uint16_t len) {
Rb r; rb_init(&r, payload, len);
char ssid[33], pwd[65];
if(!rb_zstr(&r, ssid, sizeof(ssid)) || !rb_zstr(&r, pwd, sizeof(pwd))) {
wifi_link_send_error("bad WIFI_SET"); return;
}
wifi_net_set_creds(ssid, pwd);
send_wifi_status();
}
/* ---- Main RX dispatch --------------------------------------------------- */
static void on_frame(uint8_t type, const uint8_t* payload, uint16_t len, void* user) {
(void)user;
switch(type) {
case TT_FRAME_PING: wifi_link_send(TT_FRAME_PING, NULL, 0); break;
case TT_FRAME_WIFI_SET: handle_wifi_set(payload, len); break;
case TT_FRAME_WIFI_FORGET: wifi_net_forget(); send_wifi_status(); break;
case TT_FRAME_WIFI_STATUS: send_wifi_status(); break;
case TT_FRAME_LIST_PLUGINS: handle_list_plugins(); break;
case TT_FRAME_RUN_PLUGIN: handle_run_plugin(payload, len); break;
default: ESP_LOGW(TAG, "unhandled type 0x%02X", type); break;
}
}
void app_main(void) {
ESP_LOGI(TAG, "TagTinker cloud-renderer booting");
wifi_net_init();
cloud_client_load();
wifi_link_init(on_frame, NULL);
vTaskDelay(pdMS_TO_TICKS(200));
send_hello();
while(1) {
vTaskDelay(pdMS_TO_TICKS(2000));
send_wifi_status();
}
}
@@ -0,0 +1,187 @@
/*
* Framed UART link - implementation.
*
* UART config: UART1 by default on the Flipper WiFi Dev Board. The numbers
* are picked to match the FAP-side defaults; if you change them on one side
* you must change them on the other.
*
* pin TX = GPIO17, pin RX = GPIO18, baud 230400, no flow control.
*
* RX runs in a dedicated task that re-syncs on the 0xAA 0x55 SOF whenever
* a CRC mismatch or oversize frame is seen.
*/
#include "wifi_link.h"
#include "tt_wifi_proto.h"
#include "driver/uart.h"
#include "driver/gpio.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include "esp_log.h"
#include <string.h>
#include <stdio.h>
/* The Flipper Wi-Fi Devboard wires the ESP32-S2's UART0 (default
* IO_MUX pins U0TXD=GPIO43, U0RXD=GPIO44) to the Flipper. We claim
* UART0 for our framed binary protocol; the IDF console is silenced
* via CONFIG_ESP_CONSOLE_NONE=y so the two never collide. */
#define LINK_UART_NUM UART_NUM_0
#define LINK_PIN_TX UART_PIN_NO_CHANGE
#define LINK_PIN_RX UART_PIN_NO_CHANGE
#define LINK_BAUD 230400
#define LINK_RXBUF 4096
#define LINK_TXBUF 1024
static const char* TAG = "link";
static WifiLinkRxFn s_rx_cb;
static void* s_rx_user;
static SemaphoreHandle_t s_tx_lock;
bool wifi_link_send(uint8_t type, const uint8_t* payload, uint16_t len) {
if(len > TT_FRAME_MAX_PAYLOAD) {
ESP_LOGE(TAG, "tx: payload %u over limit", (unsigned)len);
return false;
}
/* Frame buffer: 2(SOF) + 1(type) + 2(len) + payload + 2(crc). */
uint8_t hdr[5];
hdr[0] = TT_FRAME_SOF0;
hdr[1] = TT_FRAME_SOF1;
hdr[2] = type;
hdr[3] = (uint8_t)(len & 0xFFU);
hdr[4] = (uint8_t)(len >> 8);
/* CRC over [type, len_lo, len_hi, payload...]. */
uint16_t crc = 0xFFFFU;
{
for(int i = 2; i < 5; i++) {
crc ^= (uint16_t)hdr[i] << 8;
for(int b = 0; b < 8; b++)
crc = (crc & 0x8000U) ? (uint16_t)((crc << 1) ^ 0x1021U) : (uint16_t)(crc << 1);
}
for(uint16_t i = 0; i < len; i++) {
crc ^= (uint16_t)payload[i] << 8;
for(int b = 0; b < 8; b++)
crc = (crc & 0x8000U) ? (uint16_t)((crc << 1) ^ 0x1021U) : (uint16_t)(crc << 1);
}
}
uint8_t tail[2] = { (uint8_t)(crc >> 8), (uint8_t)(crc & 0xFFU) };
xSemaphoreTake(s_tx_lock, portMAX_DELAY);
int ok = uart_write_bytes(LINK_UART_NUM, (const char*)hdr, sizeof(hdr));
if(ok > 0 && len) ok = uart_write_bytes(LINK_UART_NUM, (const char*)payload, len);
if(ok > 0) ok = uart_write_bytes(LINK_UART_NUM, (const char*)tail, sizeof(tail));
xSemaphoreGive(s_tx_lock);
return ok > 0;
}
bool wifi_link_send_progress(uint8_t percent, const char* msg) {
uint8_t buf[1 + 1 + 64];
if(!msg) msg = "";
size_t mlen = strnlen(msg, sizeof(buf) - 2);
buf[0] = percent;
buf[1] = (uint8_t)mlen;
memcpy(&buf[2], msg, mlen);
return wifi_link_send(TT_FRAME_PROGRESS, buf, (uint16_t)(2 + mlen));
}
bool wifi_link_send_error(const char* msg) {
uint8_t buf[1 + 96];
if(!msg) msg = "";
size_t mlen = strnlen(msg, sizeof(buf) - 1);
buf[0] = (uint8_t)mlen;
memcpy(&buf[1], msg, mlen);
return wifi_link_send(TT_FRAME_ERROR, buf, (uint16_t)(1 + mlen));
}
/* ---- RX task ----------------------------------------------------------- */
static inline uint16_t crc16_step(uint16_t crc, uint8_t b) {
crc ^= (uint16_t)b << 8;
for(int i = 0; i < 8; i++)
crc = (crc & 0x8000U) ? (uint16_t)((crc << 1) ^ 0x1021U)
: (uint16_t)(crc << 1);
return crc;
}
static void rx_task(void* arg) {
(void)arg;
enum { S_SOF0, S_SOF1, S_TYPE, S_LEN_LO, S_LEN_HI, S_PAYLOAD, S_CRC_HI, S_CRC_LO } st = S_SOF0;
uint8_t type = 0;
uint16_t len = 0, idx = 0;
uint16_t crc_calc = 0xFFFFU;
uint16_t crc_recv = 0;
static uint8_t payload[TT_FRAME_MAX_PAYLOAD];
#define crc_step(B) (crc_calc = crc16_step(crc_calc, (B)))
while(1) {
uint8_t b;
int n = uart_read_bytes(LINK_UART_NUM, &b, 1, pdMS_TO_TICKS(1000));
if(n != 1) continue;
switch(st) {
case S_SOF0:
if(b == TT_FRAME_SOF0) st = S_SOF1;
break;
case S_SOF1:
st = (b == TT_FRAME_SOF1) ? S_TYPE : S_SOF0;
break;
case S_TYPE:
type = b; crc_calc = 0xFFFFU; crc_step(b); st = S_LEN_LO;
break;
case S_LEN_LO:
len = b; crc_step(b); st = S_LEN_HI;
break;
case S_LEN_HI:
len |= (uint16_t)b << 8;
crc_step(b);
if(len > TT_FRAME_MAX_PAYLOAD) { st = S_SOF0; break; }
idx = 0;
st = (len == 0) ? S_CRC_HI : S_PAYLOAD;
break;
case S_PAYLOAD:
payload[idx++] = b; crc_step(b);
if(idx >= len) st = S_CRC_HI;
break;
case S_CRC_HI:
crc_recv = (uint16_t)b << 8; st = S_CRC_LO;
break;
case S_CRC_LO:
crc_recv |= b;
if(crc_recv == crc_calc) {
if(s_rx_cb) s_rx_cb(type, payload, len, s_rx_user);
} else {
ESP_LOGW(TAG, "CRC mismatch type=0x%02X len=%u", type, len);
}
st = S_SOF0;
break;
}
}
}
void wifi_link_init(WifiLinkRxFn cb, void* user) {
s_rx_cb = cb;
s_rx_user = user;
s_tx_lock = xSemaphoreCreateMutex();
uart_config_t cfg = {
.baud_rate = LINK_BAUD,
.data_bits = UART_DATA_8_BITS,
.parity = UART_PARITY_DISABLE,
.stop_bits = UART_STOP_BITS_1,
.flow_ctrl = UART_HW_FLOWCTRL_DISABLE,
.source_clk = UART_SCLK_DEFAULT,
};
uart_driver_install(LINK_UART_NUM, LINK_RXBUF, LINK_TXBUF, 0, NULL, 0);
uart_param_config(LINK_UART_NUM, &cfg);
uart_set_pin(LINK_UART_NUM, LINK_PIN_TX, LINK_PIN_RX, UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE);
/* RX task also runs the frame dispatch + cloud_client_render(). The
* mbedTLS handshake allocates ~6-8 KB on the stack during ECDHE +
* cert chain validation, so 16 KB gives a comfortable margin. The
* old 4 KB stack silently overflowed and locked the task. */
xTaskCreate(rx_task, "link_rx", 16384, NULL, 6, NULL);
}
@@ -0,0 +1,27 @@
/*
* Framed UART link to the Flipper.
*
* The link is a simple two-way pipe of TT_FRAME_* records (see
* shared/tt_wifi_proto.h). All TX is funnelled through wifi_link_send_*; all
* RX is delivered to a callback registered with wifi_link_set_handler().
*/
#ifndef TT_WIFI_LINK_H
#define TT_WIFI_LINK_H
#include <stdbool.h>
#include <stdint.h>
#include <stddef.h>
typedef void (*WifiLinkRxFn)(uint8_t type, const uint8_t* payload, uint16_t len, void* user);
void wifi_link_init(WifiLinkRxFn cb, void* user);
/* Send a fully-formed frame. Returns false if the payload is oversized or
* the UART driver couldn't accept it. */
bool wifi_link_send(uint8_t type, const uint8_t* payload, uint16_t len);
/* Convenience helpers for the most common frames. */
bool wifi_link_send_progress(uint8_t percent, const char* msg);
bool wifi_link_send_error (const char* msg);
#endif /* TT_WIFI_LINK_H */
@@ -0,0 +1,141 @@
/*
* WiFi station - implementation.
*/
#include "wifi_net.h"
#include "esp_event.h"
#include "esp_log.h"
#include "esp_netif.h"
#include "esp_wifi.h"
#include "nvs.h"
#include "nvs_flash.h"
#include "freertos/FreeRTOS.h"
#include "freertos/event_groups.h"
#include "freertos/task.h"
#include <string.h>
static const char* TAG = "net";
static const char* NVS_NS = "tt_wifi";
static EventGroupHandle_t s_eg;
#define EV_CONNECTED (1U << 0)
#define EV_FAIL (1U << 1)
static char s_ssid[33];
static char s_pwd[65];
static char s_ip[16];
static int8_t s_rssi = 0;
static uint8_t s_state = TT_WIFI_DISCONNECTED;
uint8_t wifi_net_state(void) { return s_state; }
int8_t wifi_net_rssi(void) { return s_rssi; }
const char* wifi_net_ssid(void) { return s_ssid; }
const char* wifi_net_ip(void) { return s_ip; }
static void load_creds_from_nvs(void) {
nvs_handle_t h;
if(nvs_open(NVS_NS, NVS_READONLY, &h) != ESP_OK) return;
size_t l = sizeof(s_ssid);
if(nvs_get_str(h, "ssid", s_ssid, &l) != ESP_OK) s_ssid[0] = 0;
l = sizeof(s_pwd);
if(nvs_get_str(h, "pwd", s_pwd, &l) != ESP_OK) s_pwd[0] = 0;
nvs_close(h);
}
static void save_creds_to_nvs(void) {
nvs_handle_t h;
if(nvs_open(NVS_NS, NVS_READWRITE, &h) != ESP_OK) return;
nvs_set_str(h, "ssid", s_ssid);
nvs_set_str(h, "pwd", s_pwd);
nvs_commit(h);
nvs_close(h);
}
static void wifi_event_handler(void* arg, esp_event_base_t base, int32_t id, void* data) {
(void)arg;
if(base == WIFI_EVENT) {
switch(id) {
case WIFI_EVENT_STA_START:
s_state = TT_WIFI_CONNECTING;
esp_wifi_connect();
break;
case WIFI_EVENT_STA_DISCONNECTED: {
wifi_event_sta_disconnected_t* d = data;
ESP_LOGW(TAG, "disconnected reason=%d", d->reason);
if(d->reason == WIFI_REASON_NO_AP_FOUND) s_state = TT_WIFI_NO_AP;
else if(d->reason == WIFI_REASON_AUTH_FAIL ||
d->reason == WIFI_REASON_HANDSHAKE_TIMEOUT) s_state = TT_WIFI_AUTH_FAILED;
else s_state = TT_WIFI_DISCONNECTED;
xEventGroupSetBits(s_eg, EV_FAIL);
esp_wifi_connect();
break;
}
}
} else if(base == IP_EVENT && id == IP_EVENT_STA_GOT_IP) {
ip_event_got_ip_t* g = data;
snprintf(s_ip, sizeof(s_ip), IPSTR, IP2STR(&g->ip_info.ip));
s_state = TT_WIFI_CONNECTED;
xEventGroupSetBits(s_eg, EV_CONNECTED);
}
}
static void try_connect(void) {
if(!s_ssid[0]) return;
wifi_config_t cfg = {0};
strncpy((char*)cfg.sta.ssid, s_ssid, sizeof(cfg.sta.ssid) - 1);
strncpy((char*)cfg.sta.password, s_pwd, sizeof(cfg.sta.password) - 1);
cfg.sta.threshold.authmode = WIFI_AUTH_OPEN;
cfg.sta.pmf_cfg.capable = true;
esp_wifi_set_config(WIFI_IF_STA, &cfg);
esp_wifi_disconnect();
esp_wifi_connect();
}
void wifi_net_init(void) {
s_eg = xEventGroupCreate();
esp_err_t r = nvs_flash_init();
if(r == ESP_ERR_NVS_NO_FREE_PAGES || r == ESP_ERR_NVS_NEW_VERSION_FOUND) {
nvs_flash_erase(); nvs_flash_init();
}
esp_netif_init();
esp_event_loop_create_default();
esp_netif_create_default_wifi_sta();
wifi_init_config_t init = WIFI_INIT_CONFIG_DEFAULT();
esp_wifi_init(&init);
esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &wifi_event_handler, NULL);
esp_event_handler_register(IP_EVENT, IP_EVENT_STA_GOT_IP, &wifi_event_handler, NULL);
esp_wifi_set_mode(WIFI_MODE_STA);
esp_wifi_set_storage(WIFI_STORAGE_RAM);
load_creds_from_nvs();
esp_wifi_start();
if(s_ssid[0]) try_connect();
}
void wifi_net_set_creds(const char* ssid, const char* pwd) {
strncpy(s_ssid, ssid ? ssid : "", sizeof(s_ssid) - 1);
strncpy(s_pwd, pwd ? pwd : "", sizeof(s_pwd) - 1);
save_creds_to_nvs();
try_connect();
}
void wifi_net_forget(void) {
s_ssid[0] = 0; s_pwd[0] = 0;
save_creds_to_nvs();
esp_wifi_disconnect();
s_state = TT_WIFI_DISCONNECTED;
}
bool wifi_net_wait_connected(uint32_t timeout_ms) {
if(s_state == TT_WIFI_CONNECTED) return true;
EventBits_t bits = xEventGroupWaitBits(
s_eg, EV_CONNECTED, pdFALSE, pdFALSE, pdMS_TO_TICKS(timeout_ms));
return (bits & EV_CONNECTED) != 0;
}
@@ -0,0 +1,31 @@
/*
* WiFi station + NVS-backed credential storage.
*
* Credentials live under NVS namespace "tt_wifi", keys "ssid" / "pwd".
* wifi_net_init() reads them and tries to connect; if missing, the radio
* stays parked until wifi_net_set_creds() is called by the Flipper.
*/
#ifndef TT_WIFI_NET_H
#define TT_WIFI_NET_H
#include <stdbool.h>
#include <stdint.h>
#include "tt_wifi_proto.h"
void wifi_net_init(void);
/* Returns the current state. The lower 4 bits map to TT_WIFI_*. */
uint8_t wifi_net_state(void);
int8_t wifi_net_rssi (void);
const char* wifi_net_ssid(void);
const char* wifi_net_ip (void);
/* Persists creds, drops the current AP, reconnects. */
void wifi_net_set_creds(const char* ssid, const char* pwd);
void wifi_net_forget(void);
/* Block (with timeout) until connected. Returns true on success. */
bool wifi_net_wait_connected(uint32_t timeout_ms);
#endif /* TT_WIFI_NET_H */
@@ -0,0 +1,21 @@
# TagTinker WiFi firmware - partition table aligned to the hardcoded
# offsets the Flipper "ESP Flasher" app writes to. From its source
# (esp_flasher_worker.h):
#
# ESP_ADDR_BOOT = 0x01000 "Bootloader"
# ESP_ADDR_PART = 0x08000 "Partition Table"
# ESP_ADDR_NVS = 0x09000 "NVS"
# ESP_ADDR_BOOT_APP0 = 0x0E000 "boot_app0" (== otadata)
# ESP_ADDR_APP_A = 0x10000 "Firmware A" (== ota_0)
# ESP_ADDR_APP_B = 0x150000 "Firmware B" (== ota_1)
#
# Because ota_1 must live at 0x150000, we need at least 4 MB of flash;
# the Flipper Wi-Fi Devboard's ESP32-S2-WROVER carries 4 MB so this fits.
# Each app slot is 1.25 MB which leaves plenty of headroom over our
# ~744 KB image.
#
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x5000,
otadata, data, ota, 0xe000, 0x2000,
ota_0, app, ota_0, 0x10000, 0x140000,
ota_1, app, ota_1, 0x150000, 0x140000,
Can't render this file because it contains an unexpected character in line 2 and column 23.
@@ -0,0 +1,46 @@
# TagTinker WiFi firmware - ESP-IDF defaults.
# Targets the Flipper Wi-Fi Devboard (ESP32-S2-MINI; no PSRAM).
# Bigger stack for the main task: TLS handshake + JSON parse needs room.
CONFIG_ESP_MAIN_TASK_STACK_SIZE=12288
# UART0 is owned by our framed protocol to the Flipper. The IDF console
# is moved to USB-Serial-JTAG (the dev board's USB-C) so logs are visible
# during development without colliding with our binary frames.
CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y
CONFIG_ESP_CONSOLE_SECONDARY_NONE=y
CONFIG_LOG_DEFAULT_LEVEL_INFO=y
CONFIG_BOOTLOADER_LOG_LEVEL_NONE=y
CONFIG_BOOTLOADER_LOG_LEVEL=0
# Stack overflow detection: panic instead of silently wedging when a task
# blows its stack (we hit this on the link_rx task during mbedTLS).
CONFIG_FREERTOS_CHECK_STACKOVERFLOW_CANARY=y
CONFIG_FREERTOS_WATCHPOINT_END_OF_STACK=y
# WiFi tuning - we don't need the full feature matrix.
CONFIG_ESP32_WIFI_STATIC_RX_BUFFER_NUM=8
CONFIG_ESP32_WIFI_DYNAMIC_RX_BUFFER_NUM=16
CONFIG_ESP32_WIFI_DYNAMIC_TX_BUFFER_NUM=16
# mbedTLS: the small "common subset" Mozilla bundle (~20 CAs incl. the
# Cloudflare DigiCert + ISRG roots), with stock buffer sizes so we don't
# choke on large TLS records. Hardware AES/SHA accel keeps the handshake
# fast. We deliberately leave SSL_IN/OUT_CONTENT_LEN at defaults (16 KB).
CONFIG_MBEDTLS_HARDWARE_AES=y
CONFIG_MBEDTLS_HARDWARE_SHA=y
CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_CMN=y
# Use our custom OTA-capable partition table at the offsets the Flipper
# "ESP Flasher" app expects (boot=0x1000, part=0x8000, app0=0xE000,
# firmware A=0x10000, firmware B=0x150000).
CONFIG_PARTITION_TABLE_CUSTOM=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"
CONFIG_PARTITION_TABLE_FILENAME="partitions.csv"
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
CONFIG_ESPTOOLPY_FLASHSIZE="4MB"
# We don't include a phy_init partition (no room next to the ESP Flasher
# fixed offsets); use the embedded default calibration data.
CONFIG_ESP_PHY_INIT_DATA_IN_PARTITION=n
@@ -0,0 +1,4 @@
idf_component_register(
SRCS ""
INCLUDE_DIRS "."
)
@@ -0,0 +1,130 @@
/*
* TagTinker WiFi - Flipper <-> ESP32 framed UART protocol.
*
* This header is shared verbatim between the FAP and the ESP32 firmware so
* frame layouts only need to be edited in one place.
*
* Frame on the wire:
*
* +------+------+------+--------+----------+--------+
* | 0xAA | 0x55 | TYPE | LEN_LE | PAYLOAD | CRC16 |
* +------+------+------+--------+----------+--------+
* 1B 1B 1B 2B LEN B 2B
*
* - TYPE: one of TT_FRAME_* below.
* - LEN_LE: little-endian payload length (0..16383).
* - CRC16: CRC-16/CCITT-FALSE over TYPE..end-of-PAYLOAD.
*
* Direction in comments: F->E = Flipper to ESP, E->F = ESP to Flipper.
*
* String encoding: zstrings are length-prefixed (u8 len, then bytes). NUL
* terminator is *not* included on the wire. Empty strings are "\0\0".
*/
#ifndef TT_WIFI_PROTO_H
#define TT_WIFI_PROTO_H
#include <stdint.h>
#define TT_FRAME_SOF0 0xAAU
#define TT_FRAME_SOF1 0x55U
/* Maximum payload size we'll allocate buffers for on either side. Keep this
* comfortably under the ESP's RX buffer; image data is chunked into
* RESULT_CHUNK frames so this only bounds control traffic. */
#define TT_FRAME_MAX_PAYLOAD 1024U
/* Frame types -------------------------------------------------------------- */
enum {
/* Handshake / status. */
TT_FRAME_HELLO = 0x01, /* E->F: u16 fw_ver, u32 free_heap, zstring fw_name */
TT_FRAME_PING = 0x02, /* F->E: empty | E->F: empty (echo) */
/* WiFi config. */
TT_FRAME_WIFI_SET = 0x10, /* F->E: zstring ssid, zstring password */
TT_FRAME_WIFI_FORGET = 0x11, /* F->E: empty (clears NVS creds) */
TT_FRAME_WIFI_STATUS = 0x12, /* either way:
* u8 state (TT_WIFI_*),
* i8 rssi,
* zstring ssid,
* zstring ip */
/* Plugin discovery / execution. */
TT_FRAME_LIST_PLUGINS = 0x20, /* F->E: empty */
TT_FRAME_PLUGIN = 0x21, /* E->F: one frame per plugin (see below) */
TT_FRAME_PLUGINS_END = 0x22, /* E->F: end-of-list sentinel */
TT_FRAME_RUN_PLUGIN = 0x30, /* F->E: see TT_RUN_PLUGIN layout below */
TT_FRAME_PROGRESS = 0x31, /* E->F: u8 percent, zstring message */
TT_FRAME_RESULT_BEGIN = 0x32, /* E->F: u16 width, u16 height, u8 planes (1|2),
* u32 total_bytes */
TT_FRAME_RESULT_CHUNK = 0x33, /* E->F: raw plane bytes */
TT_FRAME_RESULT_END = 0x34, /* E->F: empty - all chunks delivered */
TT_FRAME_ERROR = 0x3F, /* E->F: zstring message */
};
/* WiFi state codes (TT_FRAME_WIFI_STATUS payload byte 0). */
enum {
TT_WIFI_DISCONNECTED = 0,
TT_WIFI_CONNECTING = 1,
TT_WIFI_CONNECTED = 2,
TT_WIFI_AUTH_FAILED = 3,
TT_WIFI_NO_AP = 4,
};
/*
* TT_FRAME_PLUGIN payload layout
* ------------------------------
* u8 plugin_index
* zstr id (short stable id, e.g. "crypto")
* zstr name (display name, e.g. "Crypto Price")
* zstr description (one-liner shown on the run screen)
* u8 accent_modes (bitmask: 1=mono, 2=red, 4=yellow)
* u8 param_count
* repeated param_count times:
* zstr key (machine name, e.g. "symbol")
* zstr label (display name, e.g. "Symbol")
* u8 type (TT_PARAM_*)
* zstr default_value (always a string; client parses per type)
* if type == TT_PARAM_ENUM:
* u8 option_count
* repeated option_count times: zstr option
* if type == TT_PARAM_INT:
* i32_le min
* i32_le max
*/
enum {
TT_PARAM_STRING = 0,
TT_PARAM_INT = 1,
TT_PARAM_ENUM = 2,
TT_PARAM_BOOL = 3,
};
/* TT_FRAME_RUN_PLUGIN payload layout
* ---------------------------------
* u8 plugin_index
* u16 target_w
* u16 target_h
* u8 accent (TT_ACCENT_*)
* u8 param_count
* repeated: zstr key, zstr value (string-encoded, the plugin parses)
*/
enum {
TT_ACCENT_NONE = 0, /* mono tag */
TT_ACCENT_RED = 1,
TT_ACCENT_YELLOW = 2,
};
/* CRC-16/CCITT-FALSE (poly 0x1021, init 0xFFFF, no reflect, no xor-out).
* Tiny, branchless, no table - fine for the small frames we send. */
static inline uint16_t tt_crc16(const uint8_t* data, uint32_t len) {
uint16_t crc = 0xFFFFU;
for(uint32_t i = 0; i < len; i++) {
crc ^= (uint16_t)data[i] << 8;
for(int b = 0; b < 8; b++) {
crc = (crc & 0x8000U) ? (uint16_t)((crc << 1) ^ 0x1021U) : (uint16_t)(crc << 1);
}
}
return crc;
}
#endif /* TT_WIFI_PROTO_H */
@@ -0,0 +1,174 @@
/*
* IR transmitter.
*
* TIM1 CH3N drives the built-in IR LED carrier.
* DWT->CYCCCNT handles the symbol timing so we do not need another timer.
*/
#include "tagtinker_ir.h"
#include <furi.h>
#include <furi_hal.h>
#include <furi_hal_bus.h>
#include <furi_hal_resources.h>
#include <furi_hal_gpio.h>
#include <furi_hal_cortex.h>
#include <stm32wbxx_ll_tim.h>
/* Carrier setup for the built-in IR LED on TIM1 CH3N. */
#define CARRIER_TIM TIM1
#define CARRIER_ARR (51 - 1)
#define CARRIER_CCR 25
/*
* PP4 sends two bits per symbol. The gap selects the symbol value.
* These are pre-computed CPU cycle counts at 64 MHz to avoid any
* per-call overhead in the tight timing loop.
*/
#define PP4_BURST_CYCLES 2581
static const uint32_t pp4_gap_cycles[4] = {
3871, /* symbol 0 ~60 us */
15483, /* symbol 3 ~242 us */
7741, /* symbol 2 ~121 us */
11612, /* symbol 1 ~181 us */
};
static bool ir_initialized = false;
static volatile bool ir_stop_requested = false;
static inline void carrier_on(void) {
uint32_t ccmr2 = CARRIER_TIM->CCMR2;
ccmr2 &= ~(TIM_CCMR2_OC3M);
ccmr2 |= (TIM_CCMR2_OC3M_2 | TIM_CCMR2_OC3M_1 | TIM_CCMR2_OC3M_0); /* PWM2 */
CARRIER_TIM->CCMR2 = ccmr2;
}
static inline void carrier_off(void) {
uint32_t ccmr2 = CARRIER_TIM->CCMR2;
ccmr2 &= ~(TIM_CCMR2_OC3M);
ccmr2 |= TIM_CCMR2_OC3M_2; /* Force inactive */
CARRIER_TIM->CCMR2 = ccmr2;
}
static inline void delay_cycles(uint32_t cycles) {
uint32_t start = DWT->CYCCNT;
while((DWT->CYCCNT - start) < cycles) {
}
}
static void send_frame_pp4(const uint8_t* data, size_t len) {
for(size_t byte_idx = 0; byte_idx < len; byte_idx++) {
uint8_t current_byte = data[byte_idx];
for(int sym = 0; sym < 4; sym++) {
uint8_t symbol = current_byte & 0x03;
current_byte >>= 2;
carrier_on();
delay_cycles(PP4_BURST_CYCLES);
carrier_off();
delay_cycles(pp4_gap_cycles[symbol]);
}
}
/* Final closing burst */
carrier_on();
delay_cycles(PP4_BURST_CYCLES);
carrier_off();
}
void tagtinker_ir_init(void) {
if(ir_initialized) return;
if(furi_hal_bus_is_enabled(FuriHalBusTIM1)) {
furi_hal_bus_disable(FuriHalBusTIM1);
}
furi_hal_bus_enable(FuriHalBusTIM1);
furi_hal_gpio_init_ex(
&gpio_infrared_tx,
GpioModeAltFunctionPushPull,
GpioPullNo,
GpioSpeedVeryHigh,
GpioAltFn1TIM1);
LL_TIM_SetPrescaler(CARRIER_TIM, 0);
LL_TIM_SetAutoReload(CARRIER_TIM, CARRIER_ARR);
LL_TIM_SetCounter(CARRIER_TIM, 0);
LL_TIM_OC_SetMode(CARRIER_TIM, LL_TIM_CHANNEL_CH3, LL_TIM_OCMODE_PWM2);
LL_TIM_OC_SetCompareCH3(CARRIER_TIM, CARRIER_CCR);
LL_TIM_OC_EnablePreload(CARRIER_TIM, LL_TIM_CHANNEL_CH3);
LL_TIM_CC_EnableChannel(CARRIER_TIM, LL_TIM_CHANNEL_CH3N);
LL_TIM_EnableAllOutputs(CARRIER_TIM);
carrier_off();
LL_TIM_EnableCounter(CARRIER_TIM);
LL_TIM_GenerateEvent_UPDATE(CARRIER_TIM);
ir_stop_requested = false;
ir_initialized = true;
}
void tagtinker_ir_deinit(void) {
if(!ir_initialized) return;
tagtinker_ir_stop();
carrier_off();
LL_TIM_DisableAllOutputs(CARRIER_TIM);
LL_TIM_CC_DisableChannel(CARRIER_TIM, LL_TIM_CHANNEL_CH3N);
LL_TIM_DisableCounter(CARRIER_TIM);
if(furi_hal_bus_is_enabled(FuriHalBusTIM1)) {
furi_hal_bus_disable(FuriHalBusTIM1);
}
furi_hal_gpio_init(&gpio_infrared_tx, GpioModeAnalog, GpioPullNo, GpioSpeedLow);
ir_initialized = false;
}
bool tagtinker_ir_transmit(const uint8_t* data, size_t len, uint16_t repeats_raw, uint8_t delay) {
if(!ir_initialized) return false;
if(!data || len == 0 || len > 255) return false;
ir_stop_requested = false;
uint32_t repeats = repeats_raw & 0x7FFF;
for(uint32_t rep = 0; rep <= repeats; rep++) {
if(ir_stop_requested) {
carrier_off();
return false;
}
/*
* FURI_CRITICAL_ENTER/EXIT wraps each individual frame to prevent
* OS interrupts from breaking the microsecond-level IR symbol timing.
* The critical section is short (~1-2ms per frame) so it won't stall
* the OS noticeably, and we yield via furi_delay_ms between repeats.
*/
FURI_CRITICAL_ENTER();
send_frame_pp4(data, len);
FURI_CRITICAL_EXIT();
if(rep < repeats) {
/* Short gap between repeats using cycle-accurate delay.
* delay parameter = gap in units of 500µs.
* Always yield to OS every 5 repeats to prevent watchdog starvation. */
if(delay > 0) {
uint32_t gap_cycles = (uint32_t)delay * 32000U; /* 500µs per unit at 64MHz */
delay_cycles(gap_cycles);
}
/* Yield to OS periodically */
if((rep % 5U) == 4U) furi_delay_ms(1);
}
}
return true;
}
void tagtinker_ir_stop(void) {
ir_stop_requested = true;
carrier_off();
}
@@ -0,0 +1,22 @@
/*
* IR transmitter API.
*
* Sends already-built ESL frames over the built-in IR LED.
*/
#pragma once
#include <stdint.h>
#include <stddef.h>
#include <stdbool.h>
void tagtinker_ir_init(void);
void tagtinker_ir_deinit(void);
/* Repeats are sent with PP4 timing; the high bit is ignored for legacy saved data. */
bool tagtinker_ir_transmit(const uint8_t* data, size_t len, uint16_t repeats, uint8_t delay);
bool tagtinker_ir_is_busy(void);
void tagtinker_ir_stop(void);
@@ -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);
}
@@ -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]);
@@ -0,0 +1,312 @@
/*
* ESL protocol helpers.
*/
#include "tagtinker_proto.h"
#include "../tagtinker_app.h"
#include <string.h>
#include <stdlib.h>
typedef struct {
uint16_t type_code;
uint16_t width;
uint16_t height;
TagTinkerTagKind kind;
TagTinkerTagColor color;
const char* model_name;
uint8_t pl_bit_def;
} TagTinkerProfileEntry;
static const TagTinkerProfileEntry profile_table[] = {
{1206, 0, 0, TagTinkerTagKindSegment, TagTinkerTagColorMono, "Continuum E2 HCS", 0},
{1207, 0, 0, TagTinkerTagKindSegment, TagTinkerTagColorMono, "Continuum E2 HCN", 4},
{1217, 0, 0, TagTinkerTagKindSegment, TagTinkerTagColorMono, "Continuum E5 HCS", 2},
{1219, 0, 0, TagTinkerTagKindSegment, TagTinkerTagColorMono, "Continuum E5 HCN", 1},
{1240, 0, 0, TagTinkerTagKindSegment, TagTinkerTagColorMono, "Continuum E4 HCS", 3},
{1241, 0, 0, TagTinkerTagKindSegment, TagTinkerTagColorMono, "Continuum E4 HCN", 0},
{1242, 0, 0, TagTinkerTagKindSegment, TagTinkerTagColorMono, "Continuum E4 HCN FZ", 0},
{1243, 0, 0, TagTinkerTagKindSegment, TagTinkerTagColorMono, "Continuum E4 HCW", 0},
{1265, 0, 0, TagTinkerTagKindSegment, TagTinkerTagColorMono, "Continuum E5 HCS", 2},
{1275, 320, 192, TagTinkerTagKindDotMatrix, TagTinkerTagColorMono, "DM110", 0},
{1276, 320, 140, TagTinkerTagKindDotMatrix, TagTinkerTagColorMono, "DM90", 0},
{1291, 0, 0, TagTinkerTagKindSegment, TagTinkerTagColorMono, "FVL Promoline 3-16", 0},
{1300, 172, 72, TagTinkerTagKindDotMatrix, TagTinkerTagColorMono, "DM3370", 0},
{1314, 400, 300, TagTinkerTagKindDotMatrix, TagTinkerTagColorMono, "SmartTag HD110", 0},
{1315, 296, 128, TagTinkerTagKindDotMatrix, TagTinkerTagColorMono, "SmartTag HD L", 0},
{1317, 152, 152, TagTinkerTagKindDotMatrix, TagTinkerTagColorMono, "SmartTag HD S", 0},
{1318, 208, 112, TagTinkerTagKindDotMatrix, TagTinkerTagColorMono, "SmartTag HD M", 0},
{1319, 800, 480, TagTinkerTagKindDotMatrix, TagTinkerTagColorMono, "SmartTag HD200", 0},
{1322, 152, 152, TagTinkerTagKindDotMatrix, TagTinkerTagColorMono, "SmartTag HD S", 0},
{1324, 208, 112, TagTinkerTagKindDotMatrix, TagTinkerTagColorMono, "SmartTag HD M FZ", 0},
{1327, 208, 112, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed, "SmartTag HD M Red", 0},
{1328, 296, 128, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed, "SmartTag HD L Red", 0},
{1336, 400, 300, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed, "SmartTag HD110 Red", 0},
{1339, 152, 152, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed, "SmartTag HD S Red", 0},
{1340, 800, 480, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed, "SmartTag HD200 Red", 0},
{1344, 296, 128, TagTinkerTagKindDotMatrix, TagTinkerTagColorYellow, "SmartTag HD L Yellow", 0},
{1346, 800, 480, TagTinkerTagKindDotMatrix, TagTinkerTagColorYellow, "SmartTag HD200 Yellow", 0},
{1348, 264, 176, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed, "SmartTag HD T Red", 0},
{1349, 264, 176, TagTinkerTagKindDotMatrix, TagTinkerTagColorYellow, "SmartTag HD T Yellow", 0},
{1351, 648, 480, TagTinkerTagKindDotMatrix, TagTinkerTagColorMono, "SmartTag HD150", 0},
{1353, 648, 480, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed, "SmartTag HD150 Red", 0},
{1354, 648, 480, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed, "SmartTag HD150 Red", 0},
{1370, 296, 128, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed, "SmartTag HD L Red (2021)", 0},
{1371, 648, 480, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed, "SmartTag HD150 Red (2021)", 0},
{1510, 0, 0, TagTinkerTagKindSegment, TagTinkerTagColorMono, "SmartTag E5 M", 1},
{1627, 296, 128, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed, "SmartTag HD L Red", 0},
{1628, 296, 128, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed, "SmartTag HD L Red", 0},
{1639, 152, 152, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed, "SmartTag HD S Red", 0},
};
static const TagTinkerProfileEntry* find_profile_entry(uint16_t type_code) {
for(size_t i = 0; i < COUNT_OF(profile_table); i++) {
if(profile_table[i].type_code == type_code) return &profile_table[i];
}
return NULL;
}
uint16_t tagtinker_crc16(const uint8_t* data, size_t len) {
uint16_t crc = 0x8408;
for(size_t i = 0; i < len; i++) {
crc ^= data[i];
for(int b = 0; b < 8; b++)
crc = (crc & 1) ? (crc >> 1) ^ 0x8408 : crc >> 1;
}
return crc;
}
static size_t terminate(uint8_t* buf, size_t len) {
uint16_t crc = tagtinker_crc16(buf, len);
buf[len] = crc & 0xFF;
buf[len + 1] = (crc >> 8) & 0xFF;
return len + 2;
}
static size_t raw_frame(uint8_t* buf, uint8_t proto, const uint8_t plid[4], uint8_t cmd) {
buf[0] = proto;
memcpy(&buf[1], plid, 4);
buf[5] = cmd;
return 6;
}
static size_t mcu_frame(uint8_t* buf, const uint8_t plid[4], uint8_t cmd) {
size_t p = raw_frame(buf, TAGTINKER_PROTO_DM, plid, 0x34);
buf[p++] = 0x00;
buf[p++] = 0x00;
buf[p++] = 0x00;
buf[p++] = cmd;
return p;
}
static void append_word(uint8_t* buf, size_t* p, uint16_t value) {
buf[(*p)++] = (value >> 8) & 0xFF;
buf[(*p)++] = value & 0xFF;
}
bool tagtinker_barcode_to_plid(const char* barcode, uint8_t plid[4]) {
if(!barcode || strlen(barcode) != 17) return false;
uint64_t a = 0, b = 0;
for(int i = 2; i < 7; i++) a = a * 10 + (barcode[i] - '0');
for(int i = 7; i < 12; i++) b = b * 10 + (barcode[i] - '0');
uint64_t id = (a << 16) | b;
plid[0] = id & 0xFF; // LSB first
plid[1] = (id >> 8) & 0xFF;
plid[2] = (id >> 16) & 0xFF;
plid[3] = (id >> 24) & 0xFF;
return true;
}
bool tagtinker_barcode_to_type(const char* barcode, uint16_t* type_code) {
if(!barcode || strlen(barcode) != 17 || !type_code) return false;
uint16_t type = 0;
for(int i = 12; i < 16; i++) type = type * 10 + (barcode[i] - '0');
*type_code = type;
return true;
}
bool tagtinker_barcode_to_profile(const char* barcode, TagTinkerTagProfile* profile) {
if(!profile) return false;
memset(profile, 0, sizeof(*profile));
uint16_t type_code = 0;
if(!tagtinker_barcode_to_type(barcode, &type_code)) return false;
profile->type_code = type_code;
const TagTinkerProfileEntry* entry = find_profile_entry(type_code);
if(!entry) return false;
profile->width = entry->width; profile->height = entry->height;
profile->kind = entry->kind; profile->color = entry->color;
profile->model_name = entry->model_name; profile->pl_bit_def = entry->pl_bit_def;
profile->known = true;
return true;
}
size_t tagtinker_make_ping_frame(uint8_t* buf, const uint8_t plid[4]) {
size_t p = raw_frame(buf, TAGTINKER_PROTO_DM, plid, 0x97);
buf[p++] = 0x01;
buf[p++] = 0x00;
buf[p++] = 0x00;
buf[p++] = 0x00;
for(int i = 0; i < 20; i++) buf[p++] = 0x01;
return terminate(buf, p);
}
size_t tagtinker_make_refresh_frame(uint8_t* buf, const uint8_t plid[4]) {
size_t p = mcu_frame(buf, plid, 0x01);
for(int i = 0; i < 18; i++) buf[p++] = 0x00;
return terminate(buf, p);
}
size_t tagtinker_make_image_param_frame(
uint8_t* buf, const uint8_t plid[4], uint16_t byte_count, uint8_t comp_type,
uint8_t page, uint16_t width, uint16_t height, uint16_t pos_x, uint16_t pos_y) {
size_t p = mcu_frame(buf, plid, 0x05);
append_word(buf, &p, byte_count);
buf[p++] = 0x00;
buf[p++] = comp_type;
buf[p++] = page;
append_word(buf, &p, width);
append_word(buf, &p, height);
append_word(buf, &p, pos_x);
append_word(buf, &p, pos_y);
append_word(buf, &p, 0x0000);
buf[p++] = 0x88;
append_word(buf, &p, 0x0000);
for(int i = 0; i < 4; i++) buf[p++] = 0x00;
return terminate(buf, p);
}
size_t tagtinker_make_image_data_frame(
uint8_t* buf,
const uint8_t plid[4],
uint16_t index,
const uint8_t data[TAGTINKER_IMAGE_DATA_BYTES_PER_FRAME]) {
size_t p = mcu_frame(buf, plid, 0x20);
append_word(buf, &p, index);
memcpy(&buf[p], data, TAGTINKER_IMAGE_DATA_BYTES_PER_FRAME);
p += TAGTINKER_IMAGE_DATA_BYTES_PER_FRAME;
return terminate(buf, p);
}
typedef struct { uint8_t* data; size_t bit_pos; } TagTinkerBitWriter;
static inline void bit_writer_append(TagTinkerBitWriter* writer, uint8_t bit) {
size_t byte_idx = writer->bit_pos / 8U;
size_t bit_idx = 7U - (writer->bit_pos % 8U);
if(bit) writer->data[byte_idx] |= (uint8_t)(1U << bit_idx);
writer->bit_pos++;
}
static size_t record_run_bit_length(uint32_t count) {
size_t bits = 0; do { bits++; count >>= 1; } while(count);
return (bits * 2U) - 1U;
}
static void bit_writer_append_run(TagTinkerBitWriter* writer, uint32_t count) {
uint8_t bits[32]; int n = 0; uint32_t v = count;
while(v) { bits[n++] = v & 1U; v >>= 1; }
for(int i = 0; i < n / 2; i++) { uint8_t t = bits[i]; bits[i] = bits[n-1-i]; bits[n-1-i] = t; }
for(int i = 1; i < n; i++) bit_writer_append(writer, 0U);
for(int i = 0; i < n; i++) bit_writer_append(writer, bits[i]);
}
static inline uint8_t plane_pixel_at(const uint8_t* p1, const uint8_t* p2, size_t count, size_t idx) {
return (idx < count) ? p1[idx] : p2[idx - count];
}
static size_t tagtinker_rle_planes_bit_length(const uint8_t* p1, const uint8_t* p2, size_t count) {
if(!p1) return 0;
size_t total = p2 ? (count * 2U) : count;
if(total == 0) return 0;
size_t bit_len = 1U;
uint8_t run_pixel = plane_pixel_at(p1, p2, count, 0);
uint32_t run_count = 1;
for(size_t i = 1; i < total; i++) {
uint8_t pix = plane_pixel_at(p1, p2, count, i);
if(pix == run_pixel) run_count++;
else { bit_len += record_run_bit_length(run_count); run_pixel = pix; run_count = 1; }
}
if(run_count > 0U) bit_len += record_run_bit_length(run_count);
return bit_len;
}
static void tagtinker_pack_planes_raw(const uint8_t* p1, const uint8_t* p2, size_t count, uint8_t* out) {
size_t total = p2 ? (count * 2U) : count;
TagTinkerBitWriter writer = {.data = out, .bit_pos = 0};
for(size_t i = 0; i < total; i++) bit_writer_append(&writer, plane_pixel_at(p1, p2, count, i));
}
static void tagtinker_pack_planes_rle(const uint8_t* p1, const uint8_t* p2, size_t count, uint8_t* out) {
size_t total = p2 ? (count * 2U) : count;
if(total == 0) return;
TagTinkerBitWriter writer = {.data = out, .bit_pos = 0};
uint8_t run_pixel = plane_pixel_at(p1, p2, count, 0);
uint32_t run_count = 1;
bit_writer_append(&writer, run_pixel);
for(size_t i = 1; i < total; i++) {
uint8_t pix = plane_pixel_at(p1, p2, count, i);
if(pix == run_pixel) run_count++;
else { bit_writer_append_run(&writer, run_count); run_pixel = pix; run_count = 1; }
}
if(run_count > 0U) bit_writer_append_run(&writer, run_count);
}
#define DATA_BITS_PER_FRAME (TAGTINKER_IMAGE_DATA_BYTES_PER_FRAME * 8U)
bool tagtinker_encode_planes_payload(
const uint8_t* p1, const uint8_t* p2, size_t count, TagTinkerCompressionMode mode, TagTinkerImagePayload* payload) {
if(!p1 || !payload) return false;
memset(payload, 0, sizeof(*payload));
size_t total = p2 ? (count * 2U) : count;
size_t comp_len = tagtinker_rle_planes_bit_length(p1, p2, count);
bool use_compressed = (mode == TagTinkerCompressionRle) ||
(mode == TagTinkerCompressionAuto && comp_len > 0U && comp_len < total);
size_t src_len = use_compressed ? comp_len : total;
size_t padded_bits = src_len + ((DATA_BITS_PER_FRAME - (src_len % DATA_BITS_PER_FRAME)) % DATA_BITS_PER_FRAME);
uint8_t* data = calloc(padded_bits / 8U, 1);
if(!data) return false;
if(use_compressed) tagtinker_pack_planes_rle(p1, p2, count, data);
else tagtinker_pack_planes_raw(p1, p2, count, data);
payload->data = data; payload->byte_count = padded_bits / 8U;
payload->comp_type = use_compressed ? 2U : 0U;
return true;
}
bool tagtinker_encode_image_payload(
const uint8_t* pixels, uint16_t width, uint16_t height, bool color_clear,
TagTinkerCompressionMode mode, TagTinkerImagePayload* payload) {
size_t count = (size_t)width * height;
uint8_t* second = NULL;
if(color_clear) { second = malloc(count); if(!second) return false; memset(second, 1, count); }
bool ok = tagtinker_encode_planes_payload(pixels, second, count, mode, payload);
free(second); return ok;
}
void tagtinker_free_image_payload(TagTinkerImagePayload* payload) {
if(payload && payload->data) { free(payload->data); payload->data = NULL; }
}
bool tagtinker_is_barcode_valid(const char* barcode) {
if(!barcode || strlen(barcode) != 17) return false;
return true;
}
size_t tagtinker_make_addressed_frame(uint8_t* buf, const uint8_t plid[4], const uint8_t* payload, size_t len) {
size_t p = raw_frame(buf, TAGTINKER_PROTO_DM, plid, payload[0]);
memcpy(&buf[p], payload + 1, len - 1); p += len - 1;
return terminate(buf, p);
}
size_t tagtinker_build_broadcast_page_frame(uint8_t* buf, uint8_t page, bool forever, uint16_t duration) {
const uint8_t plid[4] = {0};
size_t p = raw_frame(buf, TAGTINKER_PROTO_DM, plid, 0x06);
buf[p++] = ((page & 7) << 3) | 0x01 | (forever ? 0x80 : 0x00);
buf[p++] = 0x00; buf[p++] = 0x00;
buf[p++] = (duration >> 8) & 0xFF; buf[p++] = duration & 0xFF;
return terminate(buf, p);
}
size_t tagtinker_build_broadcast_debug_frame(uint8_t* buf) {
const uint8_t plid[4] = {0};
size_t p = raw_frame(buf, TAGTINKER_PROTO_DM, plid, 0x06);
buf[p++] = 0xF1; buf[p++] = 0x00; buf[p++] = 0x00; buf[p++] = 0x00; buf[p++] = 0x0A;
return terminate(buf, p);
}
@@ -0,0 +1,129 @@
/*
* ESL protocol helpers.
*
* This layer turns barcodes, pixels, and payload bytes into the frames
* that the Flipper sends over IR to the tag.
*
* SPDX-License-Identifier: GPL-3.0-only
*/
#pragma once
#include <stdint.h>
#include <stddef.h>
#include <stdbool.h>
#define TAGTINKER_PROTO_DM 0x85
#define TAGTINKER_PROTO_SEG 0x84
#define TAGTINKER_MAX_FRAME_SIZE 96
#define TAGTINKER_IMAGE_DATA_BYTES_PER_FRAME 20U
typedef struct TagTinkerApp TagTinkerApp;
/* CRC used by the ESL wire format. */
uint16_t tagtinker_crc16(const uint8_t* data, size_t len);
typedef enum {
TagTinkerTagKindUnknown = 0,
TagTinkerTagKindDotMatrix,
TagTinkerTagKindSegment,
} TagTinkerTagKind;
typedef enum {
TagTinkerTagColorMono = 0,
TagTinkerTagColorRed,
TagTinkerTagColorYellow,
} TagTinkerTagColor;
typedef struct {
uint16_t type_code;
uint16_t width;
uint16_t height;
TagTinkerTagKind kind;
TagTinkerTagColor color;
const char* model_name;
uint8_t pl_bit_def;
bool known;
} TagTinkerTagProfile;
bool tagtinker_is_barcode_valid(const char* barcode);
bool tagtinker_barcode_to_plid(const char* barcode, uint8_t plid[4]);
bool tagtinker_barcode_to_type(const char* barcode, uint16_t* type_code);
bool tagtinker_barcode_to_profile(const char* barcode, TagTinkerTagProfile* profile);
typedef struct {
uint8_t* data;
size_t byte_count;
uint8_t comp_type;
} TagTinkerImagePayload;
typedef enum {
TagTinkerCompressionAuto = 0,
TagTinkerCompressionRaw,
TagTinkerCompressionRle,
} TagTinkerCompressionMode;
bool tagtinker_encode_image_payload(
const uint8_t* pixels,
uint16_t width,
uint16_t height,
bool color_clear,
TagTinkerCompressionMode mode,
TagTinkerImagePayload* payload);
bool tagtinker_encode_planes_payload(
const uint8_t* primary_pixels,
const uint8_t* secondary_pixels,
size_t pixel_count,
TagTinkerCompressionMode mode,
TagTinkerImagePayload* payload);
void tagtinker_free_image_payload(TagTinkerImagePayload* payload);
size_t tagtinker_make_image_param_frame(
uint8_t* buf,
const uint8_t plid[4],
uint16_t byte_count,
uint8_t comp_type,
uint8_t page,
uint16_t width,
uint16_t height,
uint16_t pos_x,
uint16_t pos_y);
size_t tagtinker_make_image_data_frame(
uint8_t* buf,
const uint8_t plid[4],
uint16_t frame_index,
const uint8_t data_bytes[TAGTINKER_IMAGE_DATA_BYTES_PER_FRAME]);
/* Broadcast frames address every listening tag. */
size_t tagtinker_build_broadcast_page_frame(
uint8_t* buf, uint8_t page, bool forever, uint16_t duration);
size_t tagtinker_build_broadcast_debug_frame(uint8_t* buf);
/* Addressed frames use the PLID decoded from the barcode. */
size_t tagtinker_make_addressed_frame(
uint8_t* buf, const uint8_t plid[4],
const uint8_t* payload, size_t payload_len);
/* Tags need a wake ping before most addressed commands. */
size_t tagtinker_make_ping_frame(uint8_t* buf, const uint8_t plid[4]);
size_t tagtinker_make_refresh_frame(uint8_t* buf, const uint8_t plid[4]);
/* Image upload uses MCU frames: one parameter frame followed by data frames. */
size_t tagtinker_make_mcu_frame(
uint8_t* buf, const uint8_t plid[4], uint8_t cmd);
/* RLE is the tag's compact bitmap format. Raw mode keeps one bit per pixel. */
size_t tagtinker_rle_compress(
const uint8_t* pixels, size_t count,
uint8_t* out, size_t out_cap, uint8_t* comp_type);
/* Builds the full IR sequence: wake, image params, data chunks, refresh. */
void tagtinker_build_image_sequence(
TagTinkerApp* app,
const uint8_t plid[4],
const uint8_t* pixels,
uint16_t width, uint16_t height,
uint8_t page,
uint16_t pos_x, uint16_t pos_y,
uint16_t wake_repeats);
@@ -0,0 +1,81 @@
/*
* Scene handler table
*/
#include "tagtinker_scene.h"
void(*const tagtinker_scene_on_enter_handlers[])(void*) = {
tagtinker_scene_warning_on_enter,
tagtinker_scene_main_menu_on_enter,
tagtinker_scene_settings_on_enter,
tagtinker_scene_broadcast_menu_on_enter,
tagtinker_scene_broadcast_on_enter,
tagtinker_scene_target_menu_on_enter,
tagtinker_scene_target_actions_on_enter,
tagtinker_scene_barcode_input_on_enter,
tagtinker_scene_text_input_on_enter,
tagtinker_scene_preset_list_on_enter,
tagtinker_scene_synced_image_list_on_enter,
tagtinker_scene_size_picker_on_enter,
tagtinker_scene_image_options_on_enter,
tagtinker_scene_transmit_on_enter,
tagtinker_scene_about_on_enter,
tagtinker_scene_text_box_on_enter,
tagtinker_scene_nfc_scan_on_enter,
tagtinker_scene_wifi_plugins_on_enter,
tagtinker_scene_wifi_setup_on_enter,
tagtinker_scene_wifi_run_on_enter,
};
bool(*const tagtinker_scene_on_event_handlers[])(void*, SceneManagerEvent) = {
tagtinker_scene_warning_on_event,
tagtinker_scene_main_menu_on_event,
tagtinker_scene_settings_on_event,
tagtinker_scene_broadcast_menu_on_event,
tagtinker_scene_broadcast_on_event,
tagtinker_scene_target_menu_on_event,
tagtinker_scene_target_actions_on_event,
tagtinker_scene_barcode_input_on_event,
tagtinker_scene_text_input_on_event,
tagtinker_scene_preset_list_on_event,
tagtinker_scene_synced_image_list_on_event,
tagtinker_scene_size_picker_on_event,
tagtinker_scene_image_options_on_event,
tagtinker_scene_transmit_on_event,
tagtinker_scene_about_on_event,
tagtinker_scene_text_box_on_event,
tagtinker_scene_nfc_scan_on_event,
tagtinker_scene_wifi_plugins_on_event,
tagtinker_scene_wifi_setup_on_event,
tagtinker_scene_wifi_run_on_event,
};
void(*const tagtinker_scene_on_exit_handlers[])(void*) = {
tagtinker_scene_warning_on_exit,
tagtinker_scene_main_menu_on_exit,
tagtinker_scene_settings_on_exit,
tagtinker_scene_broadcast_menu_on_exit,
tagtinker_scene_broadcast_on_exit,
tagtinker_scene_target_menu_on_exit,
tagtinker_scene_target_actions_on_exit,
tagtinker_scene_barcode_input_on_exit,
tagtinker_scene_text_input_on_exit,
tagtinker_scene_preset_list_on_exit,
tagtinker_scene_synced_image_list_on_exit,
tagtinker_scene_size_picker_on_exit,
tagtinker_scene_image_options_on_exit,
tagtinker_scene_transmit_on_exit,
tagtinker_scene_about_on_exit,
tagtinker_scene_text_box_on_exit,
tagtinker_scene_nfc_scan_on_exit,
tagtinker_scene_wifi_plugins_on_exit,
tagtinker_scene_wifi_setup_on_exit,
tagtinker_scene_wifi_run_on_exit,
};
const SceneManagerHandlers tagtinker_scene_handlers = {
.on_enter_handlers = tagtinker_scene_on_enter_handlers,
.on_event_handlers = tagtinker_scene_on_event_handlers,
.on_exit_handlers = tagtinker_scene_on_exit_handlers,
.scene_num = TagTinkerSceneCount,
};
@@ -0,0 +1,111 @@
/*
* Scene definitions.
*/
#pragma once
#include <gui/scene_manager.h>
typedef enum {
TagTinkerSceneWarning,
TagTinkerSceneMainMenu,
TagTinkerSceneSettings,
TagTinkerSceneBroadcastMenu,
TagTinkerSceneBroadcast,
TagTinkerSceneTargetMenu,
TagTinkerSceneTargetActions,
TagTinkerSceneBarcodeInput,
TagTinkerSceneTextInput,
TagTinkerScenePresetList,
TagTinkerSceneSyncedImageList,
TagTinkerSceneSizePicker,
TagTinkerSceneImageOptions,
TagTinkerSceneTransmit,
TagTinkerSceneAbout,
TagTinkerSceneTextBox,
TagTinkerSceneNfcScan,
TagTinkerSceneWifiPlugins,
TagTinkerSceneWifiSetup,
TagTinkerSceneWifiRun,
TagTinkerSceneCount,
} TagTinkerScene;
void tagtinker_scene_warning_on_enter(void* ctx);
bool tagtinker_scene_warning_on_event(void* ctx, SceneManagerEvent event);
void tagtinker_scene_warning_on_exit(void* ctx);
void tagtinker_scene_main_menu_on_enter(void* ctx);
bool tagtinker_scene_main_menu_on_event(void* ctx, SceneManagerEvent event);
void tagtinker_scene_main_menu_on_exit(void* ctx);
void tagtinker_scene_settings_on_enter(void* ctx);
bool tagtinker_scene_settings_on_event(void* ctx, SceneManagerEvent event);
void tagtinker_scene_settings_on_exit(void* ctx);
void tagtinker_scene_broadcast_menu_on_enter(void* ctx);
bool tagtinker_scene_broadcast_menu_on_event(void* ctx, SceneManagerEvent event);
void tagtinker_scene_broadcast_menu_on_exit(void* ctx);
void tagtinker_scene_broadcast_on_enter(void* ctx);
bool tagtinker_scene_broadcast_on_event(void* ctx, SceneManagerEvent event);
void tagtinker_scene_broadcast_on_exit(void* ctx);
void tagtinker_scene_target_menu_on_enter(void* ctx);
bool tagtinker_scene_target_menu_on_event(void* ctx, SceneManagerEvent event);
void tagtinker_scene_target_menu_on_exit(void* ctx);
void tagtinker_scene_target_actions_on_enter(void* ctx);
bool tagtinker_scene_target_actions_on_event(void* ctx, SceneManagerEvent event);
void tagtinker_scene_target_actions_on_exit(void* ctx);
void tagtinker_scene_barcode_input_on_enter(void* ctx);
bool tagtinker_scene_barcode_input_on_event(void* ctx, SceneManagerEvent event);
void tagtinker_scene_barcode_input_on_exit(void* ctx);
void tagtinker_scene_text_input_on_enter(void* ctx);
bool tagtinker_scene_text_input_on_event(void* ctx, SceneManagerEvent event);
void tagtinker_scene_text_input_on_exit(void* ctx);
void tagtinker_scene_size_picker_on_enter(void* ctx);
bool tagtinker_scene_size_picker_on_event(void* ctx, SceneManagerEvent event);
void tagtinker_scene_size_picker_on_exit(void* ctx);
void tagtinker_scene_preset_list_on_enter(void* ctx);
bool tagtinker_scene_preset_list_on_event(void* ctx, SceneManagerEvent event);
void tagtinker_scene_preset_list_on_exit(void* ctx);
void tagtinker_scene_synced_image_list_on_enter(void* ctx);
bool tagtinker_scene_synced_image_list_on_event(void* ctx, SceneManagerEvent event);
void tagtinker_scene_synced_image_list_on_exit(void* ctx);
void tagtinker_scene_image_options_on_enter(void* ctx);
bool tagtinker_scene_image_options_on_event(void* ctx, SceneManagerEvent event);
void tagtinker_scene_image_options_on_exit(void* ctx);
void tagtinker_scene_transmit_on_enter(void* ctx);
bool tagtinker_scene_transmit_on_event(void* ctx, SceneManagerEvent event);
void tagtinker_scene_transmit_on_exit(void* ctx);
void tagtinker_scene_about_on_enter(void* ctx);
bool tagtinker_scene_about_on_event(void* ctx, SceneManagerEvent event);
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);
void tagtinker_scene_wifi_plugins_on_enter(void* ctx);
bool tagtinker_scene_wifi_plugins_on_event(void* ctx, SceneManagerEvent event);
void tagtinker_scene_wifi_plugins_on_exit(void* ctx);
void tagtinker_scene_wifi_setup_on_enter(void* ctx);
bool tagtinker_scene_wifi_setup_on_event(void* ctx, SceneManagerEvent event);
void tagtinker_scene_wifi_setup_on_exit(void* ctx);
void tagtinker_scene_wifi_run_on_enter(void* ctx);
bool tagtinker_scene_wifi_run_on_event(void* ctx, SceneManagerEvent event);
void tagtinker_scene_wifi_run_on_exit(void* ctx);
@@ -0,0 +1,641 @@
/*
* About and phone sync scene.
*/
#include "../tagtinker_app.h"
#include <gui/elements.h>
#define TAGTINKER_SYNC_DIR APP_DATA_PATH("sync")
#define TAGTINKER_SYNC_INDEX_PATH APP_DATA_PATH("synced_images.txt")
#define TAGTINKER_BLE_FLOW_WINDOW 8192U
#define TAGTINKER_SYNC_MAX_CHUNK_BYTES 384U
enum {
AboutEventSendLatestPhone = 1,
};
typedef struct {
uint32_t mode;
uint32_t tick;
char status_text[32];
bool can_send_latest;
char target_name[TAGTINKER_TARGET_NAME_LEN + 1];
uint32_t total_rx;
uint8_t last_bytes[3];
} AboutViewModel;
/* Prototypes */
static uint16_t ble_rx_callback(SerialServiceEvent event, void* context);
static void bt_status_cb(BtStatus status, void* context);
static void ble_set_status(TagTinkerApp* app, const char* text) {
if(!app || !text) return;
snprintf(app->ble_status_text, sizeof(app->ble_status_text), "%s", text);
}
static void ble_send_line(TagTinkerApp* app, const char* line) {
if(!app || !app->ble_serial || !line) return;
uint8_t buf[256];
size_t n = strlen(line);
if(n > sizeof(buf) - 2U) n = sizeof(buf) - 2U;
memcpy(buf, line, n);
buf[n++] = '\n';
ble_profile_serial_tx(app->ble_serial, buf, (uint16_t)n);
}
static void ble_configure_serial(TagTinkerApp* app) {
if(!app || !app->ble_serial || app->ble_serial_configured) return;
ble_profile_serial_set_event_callback(
app->ble_serial, TAGTINKER_BLE_FLOW_WINDOW, ble_rx_callback, app);
ble_profile_serial_set_rpc_active(app->ble_serial, false);
app->ble_serial_configured = true;
}
static void ble_sync_start(TagTinkerApp* app) {
if(!app || !app->bt || app->ble_sync_active) return;
app->ble_total_rx = 0;
memset(app->ble_last_bytes, 0, 3);
app->ble_rx_len = 0;
app->ble_rx_pending_ready = false;
app->ble_serial_configured = false;
app->ble_sync_active = true;
bt_disconnect(app->bt);
bt_set_status_changed_callback(app->bt, bt_status_cb, app);
app->ble_serial = bt_profile_start(app->bt, ble_profile_serial, NULL);
if(!app->ble_serial) {
ble_set_status(app, "Serial Start Fail");
} else {
ble_set_status(app, "Waiting phone");
}
}
static void sync_clear_active_job(TagTinkerApp* app);
static void ble_sync_stop(TagTinkerApp* app) {
if(!app || !app->ble_sync_active) return;
sync_clear_active_job(app);
bt_set_status_changed_callback(app->bt, NULL, NULL);
bt_disconnect(app->bt);
bt_profile_restore_default(app->bt);
app->ble_serial = NULL;
app->ble_serial_configured = false;
app->ble_sync_active = false;
}
static uint16_t ble_rx_callback(SerialServiceEvent event, void* context) {
TagTinkerApp* app = context;
if(event.event == SerialServiceEventTypeDataReceived) {
app->ble_total_rx += event.data.size;
for(size_t i = 0; i < 3 && i < event.data.size; i++) {
app->ble_last_bytes[i] = event.data.buffer[i];
}
for(uint16_t i = 0; i < event.data.size; i++) {
char c = (char)event.data.buffer[i];
if(c == '\n' || c == '\r') {
if(app->ble_rx_len > 0U && !app->ble_rx_pending_ready) {
app->ble_rx_line[app->ble_rx_len] = '\0';
strncpy(app->ble_rx_pending_line, app->ble_rx_line, 1023);
app->ble_rx_pending_ready = true;
app->ble_rx_len = 0;
}
} else if(!app->ble_rx_pending_ready && app->ble_rx_len < 1023U) {
app->ble_rx_line[app->ble_rx_len++] = c;
}
}
}
if(app->ble_rx_pending_ready) return 0U;
return (uint16_t)(1023U - app->ble_rx_len);
}
static void bt_status_cb(BtStatus status, void* context) {
TagTinkerApp* app = context;
app->ble_status = status;
if(status == BtStatusConnected) {
/* Configure the serial callback immediately on connection */
ble_configure_serial(app);
ble_set_status(app, "Connected");
ble_send_line(app, "TT_HELLO");
} else if(status == BtStatusAdvertising) {
ble_set_status(app, "Waiting phone");
}
}
static char* sync_next_token(char** cursor) {
if(!cursor || !*cursor) return NULL;
char* token = *cursor;
char* sep = strchr(token, '|');
if(sep) {
*sep = '\0';
*cursor = sep + 1;
} else {
*cursor = NULL;
}
return token;
}
static bool sync_safe_token(const char* value, size_t max_len) {
if(!value || !*value) return false;
size_t len = strlen(value);
if(len == 0U || len > max_len) return false;
for(size_t i = 0; i < len; i++) {
char c = value[i];
if((c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
c == '_' || c == '-') {
continue;
}
return false;
}
return true;
}
static void sync_send_targets(TagTinkerApp* app) {
if(!app) return;
char line[96];
snprintf(line, sizeof(line), "TT_TARGETS_BEGIN|%u", app->target_count);
ble_send_line(app, line);
for(uint8_t i = 0; i < app->target_count; i++) {
const TagTinkerTarget* target = &app->targets[i];
snprintf(line, sizeof(line), "TT_TARGET|%s|%s|%u|%u",
target->barcode, target->name, target->profile.width, target->profile.height);
ble_send_line(app, line);
}
ble_send_line(app, "TT_TARGETS_END");
}
static void sync_clear_active_job(TagTinkerApp* app) {
if(!app) return;
if(app->ble_sync_file) {
storage_file_close(app->ble_sync_file);
storage_file_free(app->ble_sync_file);
app->ble_sync_file = NULL;
}
app->ble_sync_job_active = false;
app->ble_sync_compact_protocol = false;
app->ble_sync_job_id[0] = '\0';
app->ble_sync_barcode[0] = '\0';
app->ble_sync_temp_path[0] = '\0';
app->ble_sync_final_path[0] = '\0';
app->ble_sync_expected_bytes = 0;
app->ble_sync_received_bytes = 0;
app->ble_sync_last_chunk = 0;
}
static void sync_abort_active_job(TagTinkerApp* app) {
if(!app) return;
char temp_path[TAGTINKER_IMAGE_PATH_LEN + 1];
strncpy(temp_path, app->ble_sync_temp_path, TAGTINKER_IMAGE_PATH_LEN);
temp_path[TAGTINKER_IMAGE_PATH_LEN] = '\0';
sync_clear_active_job(app);
if(temp_path[0] != '\0') {
Storage* storage = furi_record_open(RECORD_STORAGE);
storage_common_remove(storage, temp_path);
furi_record_close(RECORD_STORAGE);
}
}
static bool sync_append_index_record(
const char* job_id,
const char* barcode,
uint16_t width,
uint16_t height,
uint8_t page,
const char* image_path) {
Storage* storage = furi_record_open(RECORD_STORAGE);
storage_common_mkdir(storage, APP_DATA_PATH(""));
File* file = storage_file_alloc(storage);
bool ok = storage_file_open(file, TAGTINKER_SYNC_INDEX_PATH, FSAM_WRITE, FSOM_OPEN_APPEND);
if(!ok) {
ok = storage_file_open(file, TAGTINKER_SYNC_INDEX_PATH, FSAM_WRITE, FSOM_CREATE_ALWAYS);
}
if(ok) {
char line[384];
int len = snprintf(
line,
sizeof(line),
"%s|%s|%u|%u|%u|%s\n",
job_id,
barcode,
width,
height,
page,
image_path);
ok = (len > 0) && ((size_t)len < sizeof(line)) &&
(storage_file_write(file, line, (uint16_t)len) == (uint16_t)len);
storage_file_close(file);
}
storage_file_free(file);
furi_record_close(RECORD_STORAGE);
return ok;
}
static int8_t sync_base64_value(char c) {
if(c >= 'A' && c <= 'Z') return (int8_t)(c - 'A');
if(c >= 'a' && c <= 'z') return (int8_t)(c - 'a' + 26);
if(c >= '0' && c <= '9') return (int8_t)(c - '0' + 52);
if(c == '+' || c == '-') return 62;
if(c == '/' || c == '_') return 63;
if(c == '=') return -2;
return -1;
}
static bool sync_decode_base64(
const char* input,
uint8_t* output,
size_t output_size,
size_t* output_len) {
if(!input || !output || !output_len) return false;
size_t out_len = 0;
uint8_t quartet[4];
uint8_t quartet_len = 0;
uint8_t padding = 0;
for(const char* p = input; *p; p++) {
int8_t value = sync_base64_value(*p);
if(value == -1) return false;
if(value == -2) {
value = 0;
padding++;
}
quartet[quartet_len++] = (uint8_t)value;
if(quartet_len != 4U) continue;
if(out_len + 3U > output_size) return false;
output[out_len++] = (uint8_t)((quartet[0] << 2U) | (quartet[1] >> 4U));
if(padding < 2U) output[out_len++] = (uint8_t)((quartet[1] << 4U) | (quartet[2] >> 2U));
if(padding == 0U) output[out_len++] = (uint8_t)((quartet[2] << 6U) | quartet[3]);
quartet_len = 0;
padding = 0;
}
if(quartet_len != 0U) return false;
*output_len = out_len;
return true;
}
static bool sync_begin_job(
TagTinkerApp* app,
const char* job_id,
const char* barcode,
uint16_t width,
uint16_t height,
uint8_t page,
uint32_t byte_count,
bool compact_protocol) {
if(!app || !sync_safe_token(job_id, TAGTINKER_SYNC_JOB_ID_LEN) ||
(barcode && *barcode && !sync_safe_token(barcode, TAGTINKER_BC_LEN)) || width == 0U || height == 0U ||
page > 7U || byte_count == 0U) {
return false;
}
sync_abort_active_job(app);
Storage* storage = furi_record_open(RECORD_STORAGE);
storage_common_mkdir(storage, APP_DATA_PATH(""));
storage_common_mkdir(storage, TAGTINKER_SYNC_DIR);
snprintf(
app->ble_sync_temp_path,
sizeof(app->ble_sync_temp_path),
"%s/%s.part",
TAGTINKER_SYNC_DIR,
job_id);
snprintf(
app->ble_sync_final_path,
sizeof(app->ble_sync_final_path),
"%s/%s.bmp",
TAGTINKER_SYNC_DIR,
job_id);
storage_common_remove(storage, app->ble_sync_temp_path);
storage_common_remove(storage, app->ble_sync_final_path);
app->ble_sync_file = storage_file_alloc(storage);
bool ok = storage_file_open(app->ble_sync_file, app->ble_sync_temp_path, FSAM_WRITE, FSOM_CREATE_ALWAYS);
furi_record_close(RECORD_STORAGE);
if(!ok) {
storage_file_free(app->ble_sync_file);
app->ble_sync_file = NULL;
return false;
}
strncpy(app->ble_sync_job_id, job_id, TAGTINKER_SYNC_JOB_ID_LEN);
app->ble_sync_job_id[TAGTINKER_SYNC_JOB_ID_LEN] = '\0';
if(barcode && *barcode) {
strncpy(app->ble_sync_barcode, barcode, TAGTINKER_BC_LEN);
app->ble_sync_barcode[TAGTINKER_BC_LEN] = '\0';
} else {
app->ble_sync_barcode[0] = '\0';
}
app->ble_sync_expected_bytes = byte_count;
app->ble_sync_received_bytes = 0;
app->ble_sync_last_chunk = 0;
app->ble_synced_lines = 0;
app->img_page = page;
app->esl_width = width;
app->esl_height = height;
app->ble_sync_job_active = true;
app->ble_sync_compact_protocol = compact_protocol;
app->ble_sync_ready_target = -1;
ble_set_status(app, "Upload started");
ble_send_line(app, compact_protocol ? "AB" : "TT_ACK|BEGIN");
return true;
}
static bool sync_append_chunk(TagTinkerApp* app, uint16_t sequence, const char* payload) {
if(!app || !app->ble_sync_job_active || !payload || sequence == 0U || !app->ble_sync_file) return false;
if(sequence == app->ble_sync_last_chunk) {
char ack[32];
snprintf(ack, sizeof(ack), "TT_ACK|%u", sequence);
ble_send_line(app, ack);
return true;
}
if(sequence != (uint16_t)(app->ble_sync_last_chunk + 1U)) return false;
uint8_t decoded[TAGTINKER_SYNC_MAX_CHUNK_BYTES];
size_t decoded_len = 0;
if(!sync_decode_base64(payload, decoded, sizeof(decoded), &decoded_len)) return false;
if((app->ble_sync_received_bytes + decoded_len) > app->ble_sync_expected_bytes) return false;
if(storage_file_write(app->ble_sync_file, decoded, decoded_len) != decoded_len) return false;
app->ble_sync_received_bytes += decoded_len;
app->ble_sync_last_chunk = sequence;
app->ble_synced_lines = sequence;
snprintf(app->ble_status_text, sizeof(app->ble_status_text), "RX %u chunks", sequence);
char ack[32];
snprintf(ack, sizeof(ack), "TT_ACK|%u", sequence);
ble_send_line(app, ack);
return true;
}
static bool sync_finish_job(TagTinkerApp* app, const char* job_id) {
if(!app || !sync_safe_token(job_id, TAGTINKER_SYNC_JOB_ID_LEN)) return false;
if(!app->ble_sync_job_active && strcmp(job_id, app->ble_sync_last_job_id) == 0) {
ble_send_line(app, "TT_ACK|END");
return true;
}
if(!app->ble_sync_job_active || strcmp(job_id, app->ble_sync_job_id) != 0) return false;
if(app->ble_sync_barcode[0] == '\0') {
ble_set_status(app, "No target");
return false;
}
if(app->ble_sync_received_bytes != app->ble_sync_expected_bytes) {
ble_set_status(app, "Size mismatch");
return false;
}
if(app->ble_sync_file) {
storage_file_close(app->ble_sync_file);
storage_file_free(app->ble_sync_file);
app->ble_sync_file = NULL;
}
Storage* storage = furi_record_open(RECORD_STORAGE);
storage_common_remove(storage, app->ble_sync_final_path);
bool ok = storage_common_rename(storage, app->ble_sync_temp_path, app->ble_sync_final_path) == FSE_OK;
furi_record_close(RECORD_STORAGE);
if(!ok) {
ble_set_status(app, "Save failed");
return false;
}
ok = sync_append_index_record(
app->ble_sync_job_id,
app->ble_sync_barcode,
app->esl_width,
app->esl_height,
app->img_page,
app->ble_sync_final_path);
if(!ok) {
ble_set_status(app, "Index failed");
return false;
}
strncpy(app->ble_sync_last_job_id, app->ble_sync_job_id, TAGTINKER_SYNC_JOB_ID_LEN);
app->ble_sync_last_job_id[TAGTINKER_SYNC_JOB_ID_LEN] = '\0';
app->ble_sync_last_completed_chunks = app->ble_sync_last_chunk;
app->ble_sync_last_compact_protocol = app->ble_sync_compact_protocol;
ble_send_line(app, "TT_ACK|END");
int8_t target_index = tagtinker_ensure_target(app, app->ble_sync_barcode);
if(target_index >= 0) {
tagtinker_select_target(app, (uint8_t)target_index);
app->ble_sync_ready_target = target_index;
snprintf(
app->ble_status_text,
sizeof(app->ble_status_text),
"Saved for %.20s",
app->targets[target_index].name);
} else {
app->ble_sync_ready_target = -1;
ble_set_status(app, "Saved on Flipper");
}
app->ble_sync_job_active = false;
return true;
}
static void sync_apply_line(TagTinkerApp* app, const char* line) {
if(!app || !line) return;
if(strcmp(line, "TT_HELLO") == 0) {
ble_send_line(app, "TT_PONG");
return;
}
if(strcmp(line, "TT_PING") == 0) {
ble_set_status(app, "Handshake OK");
ble_send_line(app, "TT_PONG");
return;
}
if(strcmp(line, "TT_LIST_TARGETS") == 0) {
ble_set_status(app, "Sending targets");
sync_send_targets(app);
return;
}
if(strncmp(line, "TT_BEGIN|", 9) == 0) {
ble_set_status(app, "Got BEGIN");
char temp[256];
strncpy(temp, line, sizeof(temp) - 1U);
temp[sizeof(temp) - 1U] = '\0';
char* cursor = temp;
sync_next_token(&cursor);
char* job_id = sync_next_token(&cursor);
char* barcode = sync_next_token(&cursor);
char* width = sync_next_token(&cursor);
char* height = sync_next_token(&cursor);
char* page = sync_next_token(&cursor);
char* bytes = sync_next_token(&cursor);
if(job_id && barcode && width && height && page && bytes) {
if(sync_begin_job(
app,
job_id,
barcode,
(uint16_t)atoi(width),
(uint16_t)atoi(height),
(uint8_t)atoi(page),
(uint32_t)strtoul(bytes, NULL, 10),
false)) {
return;
} else {
ble_set_status(app, "BEGIN JOB FAIL");
return;
}
}
ble_set_status(app, "BEGIN PARSE FAIL");
return;
}
if(strncmp(line, "TT_DATA|", 8) == 0) {
char temp[1024];
strncpy(temp, line, sizeof(temp) - 1U);
temp[sizeof(temp) - 1U] = '\0';
char* cursor = temp;
sync_next_token(&cursor);
char* seq = sync_next_token(&cursor);
char* payload = sync_next_token(&cursor);
if(seq && payload && sync_append_chunk(app, (uint16_t)atoi(seq), payload)) {
return;
}
ble_set_status(app, "DATA failed");
return;
}
if(strncmp(line, "TT_END|", 7) == 0) {
const char* job_id = line + 7;
if(sync_finish_job(app, job_id)) {
return;
}
ble_set_status(app, "END failed");
return;
}
snprintf(app->ble_status_text, sizeof(app->ble_status_text), "Err: %.20s", line);
}
static void about_draw_cb(Canvas* canvas, void* _model) {
AboutViewModel* model = _model;
canvas_set_font(canvas, FontPrimary);
if(model->mode == 1U) {
char header[32];
snprintf(header, sizeof(header), "Custom Image %c", (model->tick % 2) ? '*' : ' ');
canvas_draw_str_aligned(canvas, 64, 7, AlignCenter, AlignTop, header);
canvas_set_font(canvas, FontSecondary);
if(model->can_send_latest) {
canvas_draw_str_aligned(canvas, 64, 20, AlignCenter, AlignTop, "Image received!");
canvas_draw_str_aligned(canvas, 64, 32, AlignCenter, AlignTop, model->target_name);
canvas_draw_str_aligned(canvas, 64, 46, AlignCenter, AlignTop, "Point at tag & press OK");
} else {
canvas_draw_str_aligned(canvas, 64, 19, AlignCenter, AlignTop, "1. Open TagTinker app");
canvas_draw_str_aligned(canvas, 64, 29, AlignCenter, AlignTop, "2. Connect to Flipper");
canvas_draw_str_aligned(canvas, 64, 39, AlignCenter, AlignTop, "3. Select & send image");
canvas_draw_str_aligned(canvas, 64, 52, AlignCenter, AlignTop, model->status_text);
}
if(model->can_send_latest) elements_button_center(canvas, "Send");
} else {
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");
}
}
static bool about_input_cb(InputEvent* event, void* context) {
TagTinkerApp* app = context;
if(event->type != InputTypeShort || event->key != InputKeyOk) return false;
if(app->ble_sync_ready_target < 0) return false;
view_dispatcher_send_custom_event(app->view_dispatcher, AboutEventSendLatestPhone);
return true;
}
void tagtinker_scene_about_on_enter(void* ctx) {
TagTinkerApp* app = ctx;
uint32_t mode = scene_manager_get_scene_state(app->scene_manager, TagTinkerSceneAbout);
if(!app->about_view_allocated) {
view_allocate_model(app->about_view, ViewModelTypeLockFree, sizeof(AboutViewModel));
view_set_context(app->about_view, app);
view_set_draw_callback(app->about_view, about_draw_cb);
view_set_input_callback(app->about_view, about_input_cb);
app->about_view_allocated = true;
}
AboutViewModel* model = view_get_model(app->about_view);
model->mode = mode;
model->tick = 0;
model->can_send_latest = false;
model->total_rx = 0;
memset(model->last_bytes, 0, 3);
strncpy(model->status_text, "Init...", 31);
app->ble_sync_ready_target = -1; // RESET STATE
view_commit_model(app->about_view, true);
/* Delay BLE start until GUI settles */
app->ble_sync_start_pending = (mode == 1U);
view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewAbout);
}
bool tagtinker_scene_about_on_event(void* ctx, SceneManagerEvent event) {
TagTinkerApp* app = ctx;
if(event.type == SceneManagerEventTypeCustom && event.event == AboutEventSendLatestPhone) {
if(app->ble_sync_ready_target >= 0 && app->ble_sync_ready_target < app->target_count) {
TagTinkerTarget* target = &app->targets[app->ble_sync_ready_target];
TagTinkerSyncedImage image;
if(tagtinker_find_latest_synced_image(app, target->barcode, &image)) {
tagtinker_select_target(app, (uint8_t)app->ble_sync_ready_target);
app->img_page = image.page;
app->draw_x = 0;
app->draw_y = 0;
app->color_clear = false;
tagtinker_prepare_bmp_tx(app, target->plid, image.image_path, image.width, image.height, image.page);
app->tx_spam = false;
app->ble_sync_ready_target = -1;
/* Tear down BLE before IR transmission to prevent timing interference */
app->ble_sync_start_pending = false;
ble_sync_stop(app);
scene_manager_next_scene(app->scene_manager, TagTinkerSceneTransmit);
return true;
}
}
return true;
}
if(event.type == SceneManagerEventTypeTick) {
AboutViewModel* model = view_get_model(app->about_view);
model->tick++;
/* Delayed start: Wait until 5th tick (250ms) */
if(app->ble_sync_start_pending && model->tick >= 5) {
app->ble_sync_start_pending = false;
ble_sync_start(app);
}
if(app->ble_sync_active) {
if(app->ble_status == BtStatusConnected) {
/* Serial is configured in bt_status_cb, but ensure it on tick too
* in case connection fired before bt_status_cb was set up */
ble_configure_serial(app);
}
if(app->ble_rx_pending_ready) {
char safe_line[1024];
strncpy(safe_line, app->ble_rx_pending_line, 1023);
safe_line[1023] = '\0';
app->ble_rx_pending_line[0] = '\0';
app->ble_rx_pending_ready = false;
/* Process the line FIRST (SD card write + send ACK) */
sync_apply_line(app, safe_line);
/* THEN tell BLE stack we're ready for next packet.
* Order matters: phone waits for ACK before sending next chunk,
* so notify_buffer_is_empty here is just belt-and-suspenders. */
if(app->ble_serial) ble_profile_serial_notify_buffer_is_empty(app->ble_serial);
}
model->total_rx = app->ble_total_rx;
memcpy(model->last_bytes, app->ble_last_bytes, 3);
model->can_send_latest = (app->ble_sync_ready_target >= 0);
strncpy(model->status_text, app->ble_status_text, 31);
}
view_commit_model(app->about_view, true);
return true;
}
return false;
}
void tagtinker_scene_about_on_exit(void* ctx) {
TagTinkerApp* app = ctx;
app->ble_sync_start_pending = false;
ble_sync_stop(app);
}
@@ -0,0 +1,78 @@
/*
* Barcode input scene.
*/
#include "../tagtinker_app.h"
static void unsupported_tag_back_cb(GuiButtonType type, InputType input_type, void* ctx) {
UNUSED(type);
UNUSED(input_type);
TagTinkerApp* app = ctx;
scene_manager_previous_scene(app->scene_manager);
}
static void numlock_done(void* ctx, const char* barcode) {
TagTinkerApp* app = ctx;
strncpy(app->barcode, barcode, TAGTINKER_BC_LEN);
app->barcode[TAGTINKER_BC_LEN] = '\0';
view_dispatcher_send_custom_event(app->view_dispatcher, 0);
}
void tagtinker_scene_barcode_input_on_enter(void* ctx) {
TagTinkerApp* app = ctx;
numlock_input_reset(app->numlock);
numlock_input_set_callback(app->numlock, numlock_done, app);
view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewNumlock);
}
bool tagtinker_scene_barcode_input_on_event(void* ctx, SceneManagerEvent event) {
TagTinkerApp* app = ctx;
if(event.type != SceneManagerEventTypeCustom) return false;
app->barcode_valid = tagtinker_is_barcode_valid(app->barcode);
if(!app->barcode_valid) {
popup_reset(app->popup);
popup_set_header(app->popup, "Invalid Barcode", 64, 20, AlignCenter, AlignCenter);
popup_set_text(app->popup,
"Format: Letter + 16 digits",
64, 40, 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;
}
TagTinkerTagProfile profile;
if(!tagtinker_barcode_to_profile(app->barcode, &profile)) {
widget_reset(app->widget);
widget_add_string_element(app->widget, 64, 10, AlignCenter, AlignTop, FontPrimary, "Unsupported Tag");
widget_add_string_multiline_element(app->widget, 64, 30, AlignCenter, AlignTop, FontSecondary, "No profile detected\nfor this barcode.");
widget_add_button_element(app->widget, GuiButtonTypeLeft, "Back", unsupported_tag_back_cb, app);
view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewWidget);
return true;
}
app->selected_target = tagtinker_ensure_target(app, app->barcode);
if(app->selected_target >= 0) {
tagtinker_select_target(app, (uint8_t)app->selected_target);
}
uint32_t target_scene = scene_manager_get_scene_state(
app->scene_manager, TagTinkerSceneBarcodeInput);
scene_manager_next_scene(app->scene_manager, target_scene);
return true;
}
void tagtinker_scene_barcode_input_on_exit(void* ctx) {
TagTinkerApp* app = ctx;
numlock_input_reset(app->numlock);
}
@@ -0,0 +1,178 @@
/**
* Broadcast page scene
*
* Sends either a page-select command or the diagnostic page command
* to all DM ESLs in range.
* No barcode needed - uses PLID 0 (broadcast).
* Settings: page (0-7), duration (2-120s), forever toggle.
*/
#include "../tagtinker_app.h"
enum {
BroadcastItemPage,
BroadcastItemDuration,
BroadcastItemForever,
BroadcastItemRepeats,
BroadcastItemSpam,
};
static const char* page_labels[] = {"0", "1", "2", "3", "4", "5", "6", "7"};
static const char* forever_labels[] = {"Off", "On"};
static const char* repeat_labels[] = {"50", "100", "200", "400", "800"};
static const uint16_t repeat_values[] = {50, 100, 200, 400, 800};
#define REPEAT_COUNT 5
static void page_changed(VariableItem* item) {
TagTinkerApp* app = variable_item_get_context(item);
uint8_t idx = variable_item_get_current_value_index(item);
variable_item_set_current_value_text(item, page_labels[idx]);
app->page = idx;
}
static void duration_changed(VariableItem* item) {
TagTinkerApp* app = variable_item_get_context(item);
uint8_t idx = variable_item_get_current_value_index(item);
uint16_t durations[] = {2, 5, 10, 15, 30, 60, 120};
const char* dur_labels[] = {"2s", "5s", "10s", "15s", "30s", "60s", "120s"};
variable_item_set_current_value_text(item, dur_labels[idx]);
app->duration = durations[idx];
}
static void forever_changed(VariableItem* item) {
TagTinkerApp* app = variable_item_get_context(item);
uint8_t idx = variable_item_get_current_value_index(item);
variable_item_set_current_value_text(item, forever_labels[idx]);
app->forever = (idx == 1);
}
static void spam_changed(VariableItem* item) {
TagTinkerApp* app = variable_item_get_context(item);
uint8_t idx = variable_item_get_current_value_index(item);
variable_item_set_current_value_text(item, forever_labels[idx]);
app->tx_spam = (idx == 1);
}
static void repeats_changed(VariableItem* item) {
TagTinkerApp* app = variable_item_get_context(item);
uint8_t idx = variable_item_get_current_value_index(item);
variable_item_set_current_value_text(item, repeat_labels[idx]);
app->repeats = repeat_values[idx];
}
/* Row index of the ">> Transmit <<" item. Set when the list is built
* because the row count differs between flip-page (Page/Duration/Forever
* + Repeats/Repeat = 5 rows above Transmit) and diagnostic (just
* Repeats/Repeat = 2 rows above Transmit). */
static uint8_t s_transmit_row_index = 0;
static void broadcast_enter_callback(void* context, uint32_t index) {
TagTinkerApp* app = context;
/* VariableItemList fires this on OK for ANY row. We only want OK on
* the explicit ">> Transmit <<" row to start the broadcast - pressing
* OK on the Page / Duration / Forever / Repeats rows used to also
* start a transmit, which made it impossible to actually open those
* settings without sending stuff to every tag in the room. */
if(index != s_transmit_row_index) return;
view_dispatcher_send_custom_event(app->view_dispatcher, 0);
}
void tagtinker_scene_broadcast_on_enter(void* context) {
TagTinkerApp* app = context;
VariableItemList* vil = app->var_item_list;
variable_item_list_reset(vil);
VariableItem* item;
/* Build list conditionally */
if(app->broadcast_type == TagTinkerBroadcastFlipPage) {
/* Page selector 0-7 */
item = variable_item_list_add(vil, "Page", 8, page_changed, app);
variable_item_set_current_value_index(item, app->page);
variable_item_set_current_value_text(item, page_labels[app->page]);
/* Duration */
item = variable_item_list_add(vil, "Duration", 7, duration_changed, app);
/* Find closest index */
uint16_t durations[] = {2, 5, 10, 15, 30, 60, 120};
uint8_t dur_idx = 3; /* default 15s */
for(uint8_t i = 0; i < 7; i++) {
if(durations[i] == app->duration) { dur_idx = i; break; }
}
const char* dur_labels[] = {"2s", "5s", "10s", "15s", "30s", "60s", "120s"};
variable_item_set_current_value_index(item, dur_idx);
variable_item_set_current_value_text(item, dur_labels[dur_idx]);
/* Forever toggle */
item = variable_item_list_add(vil, "Forever", 2, forever_changed, app);
variable_item_set_current_value_index(item, app->forever ? 1 : 0);
variable_item_set_current_value_text(item, forever_labels[app->forever ? 1 : 0]);
}
/* Repeats (for both) */
item = variable_item_list_add(vil, "Repeats", REPEAT_COUNT, repeats_changed, app);
uint8_t rep_idx = 2; /* default 200 */
for(uint8_t i = 0; i < REPEAT_COUNT; i++) {
if(repeat_values[i] == app->repeats) { rep_idx = i; break; }
}
variable_item_set_current_value_index(item, rep_idx);
variable_item_set_current_value_text(item, repeat_labels[rep_idx]);
/* Continuous repeat toggle */
item = variable_item_list_add(vil, "Repeat", 2, spam_changed, app);
variable_item_set_current_value_index(item, app->tx_spam ? 1 : 0);
variable_item_set_current_value_text(item, forever_labels[app->tx_spam ? 1 : 0]);
/* The Transmit row is whatever index comes after everything we've
* added so far. Diagnostic mode skips Page/Duration/Forever, hence
* the index isn't a constant. */
s_transmit_row_index =
(app->broadcast_type == TagTinkerBroadcastFlipPage) ? 5 : 2;
variable_item_list_add(vil, ">> Transmit <<", 0, NULL, app);
/* The list calls back on OK for ANY row; broadcast_enter_callback
* filters by row index so only the Transmit row actually starts
* the broadcast.
*
* Cursor is intentionally LEFT on row 0 (the first setting) and
* NOT on Transmit. Reason: the OK key-press that picked us out of
* the previous Submenu generates a key-release input event that
* arrives just after this scene's VIL becomes active. If the
* cursor were on the Transmit row at that moment the stale OK
* would auto-fire the enter callback and the radio would start
* blasting before the user touched anything. Starting on row 0
* (which has a change_callback) means stale OKs are harmless. */
variable_item_list_set_enter_callback(vil, broadcast_enter_callback, app);
variable_item_list_set_selected_item(vil, 0);
view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewVarItemList);
}
bool tagtinker_scene_broadcast_on_event(void* context, SceneManagerEvent event) {
TagTinkerApp* app = context;
bool consumed = false;
if(event.type == SceneManagerEventTypeCustom) {
if(app->broadcast_type == TagTinkerBroadcastFlipPage) {
/* Build page broadcast frame and transmit */
app->frame_len = tagtinker_build_broadcast_page_frame(
app->frame_buf, app->page, app->forever, app->duration);
} else {
/* Build debug broadcast frame */
app->frame_len = tagtinker_build_broadcast_debug_frame(app->frame_buf);
}
/* Set up single-frame TX */
app->frame_seq_count = 0; /* Use single frame mode */
scene_manager_next_scene(app->scene_manager, TagTinkerSceneTransmit);
consumed = true;
}
return consumed;
}
void tagtinker_scene_broadcast_on_exit(void* context) {
TagTinkerApp* app = context;
variable_item_list_reset(app->var_item_list);
}
@@ -0,0 +1,50 @@
/*
* Broadcast menu - page select / diagnostic page
*/
#include "../tagtinker_app.h"
static void broadcast_menu_cb(void* ctx, uint32_t index) {
TagTinkerApp* app = ctx;
view_dispatcher_send_custom_event(app->view_dispatcher, index);
}
void tagtinker_scene_broadcast_menu_on_enter(void* ctx) {
TagTinkerApp* app = ctx;
submenu_reset(app->submenu);
submenu_set_header(app->submenu, "Broadcast Payloads");
submenu_add_item(app->submenu, "Change Page", TagTinkerBroadcastFlipPage, broadcast_menu_cb, app);
submenu_add_item(app->submenu, "Diagnostic Page", TagTinkerBroadcastDebugScreen, broadcast_menu_cb, app);
submenu_set_selected_item(
app->submenu,
scene_manager_get_scene_state(app->scene_manager, TagTinkerSceneBroadcastMenu));
view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewSubmenu);
}
bool tagtinker_scene_broadcast_menu_on_event(void* ctx, SceneManagerEvent event) {
TagTinkerApp* app = ctx;
if(event.type != SceneManagerEventTypeCustom) return false;
scene_manager_set_scene_state(app->scene_manager, TagTinkerSceneBroadcastMenu, event.event);
switch(event.event) {
case TagTinkerBroadcastFlipPage:
app->broadcast_type = TagTinkerBroadcastFlipPage;
scene_manager_next_scene(app->scene_manager, TagTinkerSceneBroadcast);
return true;
case TagTinkerBroadcastDebugScreen:
app->broadcast_type = TagTinkerBroadcastDebugScreen;
scene_manager_next_scene(app->scene_manager, TagTinkerSceneBroadcast);
return true;
}
return false;
}
void tagtinker_scene_broadcast_menu_on_exit(void* ctx) {
TagTinkerApp* app = ctx;
submenu_reset(app->submenu);
}
@@ -0,0 +1,72 @@
#include "../tagtinker_app.h"
enum {
ImageOptionPage,
ImageOptionTransmit,
};
static void image_option_page_changed(VariableItem* item) {
TagTinkerApp* app = variable_item_get_context(item);
app->image_tx_job.page = variable_item_get_current_value_index(item);
app->img_page = app->image_tx_job.page;
char buf[4];
snprintf(buf, sizeof(buf), "%u", app->image_tx_job.page);
variable_item_set_current_value_text(item, buf);
}
static void image_option_enter_cb(void* ctx, uint32_t index) {
TagTinkerApp* app = ctx;
if(index != ImageOptionTransmit) return;
app->tx_spam = false;
scene_manager_next_scene(app->scene_manager, TagTinkerSceneTransmit);
}
void tagtinker_scene_image_options_on_enter(void* ctx) {
TagTinkerApp* app = ctx;
VariableItemList* list = app->var_item_list;
TagTinkerImageTxJob* job = &app->image_tx_job;
if(job->mode != TagTinkerTxModeBmpImage) {
scene_manager_previous_scene(app->scene_manager);
return;
}
variable_item_list_reset(list);
VariableItem* item =
variable_item_list_add(list, "Page", 8, image_option_page_changed, app);
variable_item_set_current_value_index(item, job->page);
{
char buf[4];
snprintf(buf, sizeof(buf), "%u", job->page);
variable_item_set_current_value_text(item, buf);
}
/* Position, compression and frame-repeat are intentionally fixed here:
* pages are the only knob that meaningfully changes per-image, the rest
* are best left at their defaults (top-left, auto RLE, x2 frame repeat).
* Power users can still tune them in Settings. */
job->pos_x = 0;
job->pos_y = 0;
app->draw_x = 0;
app->draw_y = 0;
variable_item_list_add(list, ">> Send BMP <<", 0, NULL, app);
variable_item_list_set_enter_callback(list, image_option_enter_cb, app);
view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewVarItemList);
}
bool tagtinker_scene_image_options_on_event(void* ctx, SceneManagerEvent event) {
UNUSED(ctx);
UNUSED(event);
return false;
}
void tagtinker_scene_image_options_on_exit(void* ctx) {
TagTinkerApp* app = ctx;
variable_item_list_reset(app->var_item_list);
}
@@ -0,0 +1,64 @@
/*
* Main Menu
*/
#include "../tagtinker_app.h"
enum {
MainMenuBroadcast,
MainMenuTargetESL,
MainMenuSettings,
MainMenuAbout,
};
static void main_menu_cb(void* ctx, uint32_t index) {
TagTinkerApp* app = ctx;
view_dispatcher_send_custom_event(app->view_dispatcher, index);
}
void tagtinker_scene_main_menu_on_enter(void* ctx) {
TagTinkerApp* app = ctx;
submenu_reset(app->submenu);
submenu_set_header(app->submenu, TAGTINKER_DISPLAY_NAME " v" TAGTINKER_VERSION);
submenu_add_item(app->submenu, "Broadcast Payloads", MainMenuBroadcast, main_menu_cb, app);
submenu_add_item(app->submenu, "Targeted Payloads", MainMenuTargetESL, main_menu_cb, app);
submenu_add_item(app->submenu, "Settings", MainMenuSettings, main_menu_cb, app);
submenu_add_item(app->submenu, "About", MainMenuAbout, main_menu_cb, app);
submenu_set_selected_item(
app->submenu,
scene_manager_get_scene_state(app->scene_manager, TagTinkerSceneMainMenu));
view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewSubmenu);
}
bool tagtinker_scene_main_menu_on_event(void* ctx, SceneManagerEvent event) {
TagTinkerApp* app = ctx;
if(event.type != SceneManagerEventTypeCustom) return false;
scene_manager_set_scene_state(app->scene_manager, TagTinkerSceneMainMenu, event.event);
switch(event.event) {
case MainMenuBroadcast:
scene_manager_next_scene(app->scene_manager, TagTinkerSceneBroadcastMenu);
return true;
case MainMenuTargetESL:
scene_manager_next_scene(app->scene_manager, TagTinkerSceneTargetMenu);
return true;
case MainMenuSettings:
scene_manager_next_scene(app->scene_manager, TagTinkerSceneSettings);
return true;
case MainMenuAbout:
scene_manager_set_scene_state(app->scene_manager, TagTinkerSceneAbout, 0);
scene_manager_next_scene(app->scene_manager, TagTinkerSceneAbout);
return true;
}
return false;
}
void tagtinker_scene_main_menu_on_exit(void* ctx) {
TagTinkerApp* app = ctx;
submenu_reset(app->submenu);
}
@@ -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);
}
@@ -0,0 +1,101 @@
/*
* Recent pushes list.
* Only shows pushes compatible with the current tag profile.
*/
#include "../tagtinker_app.h"
#define EVT_ADD_NEW 200
#define EVT_RECENT 0
static void recent_list_cb(void* ctx, uint32_t index) {
TagTinkerApp* app = ctx;
view_dispatcher_send_custom_event(app->view_dispatcher, index);
}
static char recent_labels[TAGTINKER_MAX_PRESETS][48];
static uint8_t filtered_indices[TAGTINKER_MAX_PRESETS];
static uint8_t filtered_count = 0;
void tagtinker_scene_preset_list_on_enter(void* ctx) {
TagTinkerApp* app = ctx;
tagtinker_recents_load(app);
submenu_reset(app->submenu);
submenu_set_header(app->submenu, "Recent Pushes");
submenu_add_item(app->submenu, "[+] New Text",
EVT_ADD_NEW, recent_list_cb, app);
filtered_count = 0;
for(uint8_t i = 0; i < app->recent_count; i++) {
/* Filter by current tag's width/height if available */
if(app->selected_target >= 0) {
TagTinkerTarget* target = &app->targets[app->selected_target];
if(target->profile.width > 0 && target->profile.height > 0) {
if(app->recents[i].width != target->profile.width ||
app->recents[i].height != target->profile.height) {
continue; /* Incompatible size, skip */
}
}
}
filtered_indices[filtered_count] = i;
snprintf(recent_labels[filtered_count], sizeof(recent_labels[filtered_count]),
"\"%s\"",
app->recents[i].text);
submenu_add_item(app->submenu, recent_labels[filtered_count],
EVT_RECENT + filtered_count, recent_list_cb, app);
filtered_count++;
}
view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewSubmenu);
}
bool tagtinker_scene_preset_list_on_event(void* ctx, SceneManagerEvent event) {
TagTinkerApp* app = ctx;
if(event.type != SceneManagerEventTypeCustom) return false;
if(event.event == EVT_ADD_NEW) {
memset(app->text_input_buf, 0, sizeof(app->text_input_buf));
scene_manager_set_scene_state(app->scene_manager, TagTinkerSceneTextInput, 0);
scene_manager_next_scene(app->scene_manager, TagTinkerSceneTextInput);
return true;
}
uint32_t f_idx = event.event - EVT_RECENT;
if(f_idx < filtered_count) {
uint8_t r_idx = filtered_indices[f_idx];
app->esl_width = app->recents[r_idx].width;
app->esl_height = app->recents[r_idx].height;
app->img_page = app->recents[r_idx].page;
app->invert_text = app->recents[r_idx].invert;
app->color_clear = app->recents[r_idx].color_clear;
app->text_padding_pct = app->recents[r_idx].padding;
app->signal_mode = TagTinkerSignalPP4;
strncpy(app->text_input_buf, app->recents[r_idx].text,
sizeof(app->text_input_buf) - 1);
TagTinkerTarget* target = &app->targets[app->selected_target];
/* Auto-save/update recents order */
tagtinker_recents_add(app, app->text_input_buf);
FURI_LOG_I(TAGTINKER_TAG, "TX Recent: reps=%u", app->data_frame_repeats);
tagtinker_prepare_text_tx(app, target->plid);
app->tx_spam = false;
scene_manager_next_scene(app->scene_manager, TagTinkerSceneTransmit);
return true;
}
return false;
}
void tagtinker_scene_preset_list_on_exit(void* ctx) {
TagTinkerApp* app = ctx;
submenu_reset(app->submenu);
}
@@ -0,0 +1,81 @@
/*
* Settings scene
*/
#include "../tagtinker_app.h"
enum {
SettingsItemStartupWarning,
SettingsItemFrameRepeat,
SettingsItemClearRecents,
};
static const char* settings_toggle_labels[] = {"Off", "On"};
static void startup_warning_changed(VariableItem* item) {
TagTinkerApp* app = variable_item_get_context(item);
uint8_t index = variable_item_get_current_value_index(item);
app->show_startup_warning = (index == 1);
variable_item_set_current_value_text(item, settings_toggle_labels[index]);
tagtinker_settings_save(app);
}
static void frame_repeat_changed(VariableItem* item) {
TagTinkerApp* app = variable_item_get_context(item);
uint8_t index = variable_item_get_current_value_index(item);
app->data_frame_repeats = index + 1U;
char buf[4];
snprintf(buf, sizeof(buf), "%u", app->data_frame_repeats);
variable_item_set_current_value_text(item, buf);
tagtinker_settings_save(app);
}
static void clear_recents_cb(VariableItem* item) {
TagTinkerApp* app = variable_item_get_context(item);
app->recent_count = 0;
tagtinker_recents_save(app);
variable_item_set_current_value_text(item, "Cleared!");
}
void tagtinker_scene_settings_on_enter(void* ctx) {
TagTinkerApp* app = ctx;
VariableItemList* list = app->var_item_list;
variable_item_list_reset(list);
VariableItem* item = variable_item_list_add(
list, "Startup Warning", 2, startup_warning_changed, app);
variable_item_set_current_value_index(item, app->show_startup_warning ? 1 : 0);
variable_item_set_current_value_text(
item, settings_toggle_labels[app->show_startup_warning ? 1 : 0]);
item = variable_item_list_add(list, "Frame Repeat", 10, frame_repeat_changed, app);
variable_item_set_current_value_index(item, app->data_frame_repeats - 1U);
{
char buf[4];
snprintf(buf, sizeof(buf), "%u", app->data_frame_repeats);
variable_item_set_current_value_text(item, buf);
}
item = variable_item_list_add(list, "Clear Recents", 0, clear_recents_cb, app);
variable_item_set_current_value_text(item, "");
variable_item_list_set_selected_item(list, SettingsItemStartupWarning);
view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewVarItemList);
}
bool tagtinker_scene_settings_on_event(void* ctx, SceneManagerEvent event) {
UNUSED(ctx);
UNUSED(event);
return false;
}
void tagtinker_scene_settings_on_exit(void* ctx) {
TagTinkerApp* app = ctx;
variable_item_list_reset(app->var_item_list);
}
@@ -0,0 +1,128 @@
/*
* Size picker scene - streamlined for "Set Text" flow.
* Only keeps Page, Polarity, Padding, and Transmit.
*/
#include "../tagtinker_app.h"
#include "../views/tagtinker_font.h"
#include "../protocol/tagtinker_proto.h"
enum {
SettingPage,
SettingPolarity,
SettingPadding,
SettingTransmit,
};
static uint8_t text_page_to_index(uint8_t page) {
if(page == 0U) return 0U;
if(page > 8U) return 7U;
return (uint8_t)(page - 1U);
}
static void page_changed(VariableItem* item) {
TagTinkerApp* app = variable_item_get_context(item);
app->img_page = (uint8_t)(variable_item_get_current_value_index(item) + 1U);
char buf[4];
snprintf(buf, sizeof(buf), "%u", app->img_page);
variable_item_set_current_value_text(item, buf);
}
static void polarity_changed(VariableItem* item) {
TagTinkerApp* app = variable_item_get_context(item);
app->invert_text = (variable_item_get_current_value_index(item) == 1);
variable_item_set_current_value_text(
item, app->invert_text ? "W on B" : "B on W");
}
static void padding_changed(VariableItem* item) {
TagTinkerApp* app = variable_item_get_context(item);
uint8_t index = variable_item_get_current_value_index(item);
app->text_padding_pct = index * 5; /* 0, 5, 10, 15, 20, 25, 30, 35, 40 % */
char buf[8];
snprintf(buf, sizeof(buf), "%u%%", app->text_padding_pct);
variable_item_set_current_value_text(item, buf);
}
static void setting_cb(void* ctx, uint32_t index) {
TagTinkerApp* app = ctx;
if(index != SettingTransmit) return;
TagTinkerTarget* target = &app->targets[app->selected_target];
/* Auto-set best settings based on profile */
app->esl_width = target->profile.width;
app->esl_height = target->profile.height;
/* Default dot matrix to RLE if supported? PricehaxBT does this automatically. */
app->compression_mode = TagTinkerCompressionAuto;
app->signal_mode = TagTinkerSignalPP4;
FURI_LOG_I(TAGTINKER_TAG, "TX: %ux%u pg=%u inv=%d pad=%u%% reps=%u",
app->esl_width, app->esl_height, app->img_page, app->invert_text,
app->text_padding_pct, app->data_frame_repeats);
/* Auto-save to recents */
tagtinker_recents_add(app, app->text_input_buf);
tagtinker_prepare_text_tx(app, target->plid);
app->tx_spam = false;
scene_manager_next_scene(app->scene_manager, TagTinkerSceneTransmit);
}
void tagtinker_scene_size_picker_on_enter(void* ctx) {
TagTinkerApp* app = ctx;
app->signal_mode = TagTinkerSignalPP4;
if(app->img_page == 0U) app->img_page = 1U;
if(app->img_page > 8U) app->img_page = 8U;
variable_item_list_reset(app->var_item_list);
/* Page */
VariableItem* item_pg = variable_item_list_add(
app->var_item_list, "Page", 8, page_changed, app);
variable_item_set_current_value_index(item_pg, text_page_to_index(app->img_page));
{
char buf[4];
snprintf(buf, sizeof(buf), "%u", app->img_page);
variable_item_set_current_value_text(item_pg, buf);
}
/* Polarity */
VariableItem* item_col = variable_item_list_add(
app->var_item_list, "Polarity", 2, polarity_changed, app);
variable_item_set_current_value_index(item_col, app->invert_text ? 1 : 0);
variable_item_set_current_value_text(
item_col, app->invert_text ? "W on B" : "B on W");
/* Padding */
VariableItem* item_pad = variable_item_list_add(
app->var_item_list, "Padding", 9, padding_changed, app);
variable_item_set_current_value_index(item_pad, app->text_padding_pct / 5);
{
char buf[8];
snprintf(buf, sizeof(buf), "%u%%", app->text_padding_pct);
variable_item_set_current_value_text(item_pad, buf);
}
/* Transmit button */
variable_item_list_add(app->var_item_list, ">> Send to Tag <<", 0, NULL, app);
variable_item_list_set_enter_callback(app->var_item_list, setting_cb, app);
view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewVarItemList);
}
bool tagtinker_scene_size_picker_on_event(void* ctx, SceneManagerEvent event) {
UNUSED(ctx);
UNUSED(event);
return false;
}
void tagtinker_scene_size_picker_on_exit(void* ctx) {
TagTinkerApp* app = ctx;
variable_item_list_reset(app->var_item_list);
}
@@ -0,0 +1,267 @@
#include "../tagtinker_app.h"
#define EVT_SYNCED_IMAGE_BASE 300
#define TAGTINKER_DROPPED_DIR APP_DATA_PATH("dropped")
/* Build a synced image entry from any .bmp file in the dropped/ folder. The
* Flipper rescales BMPs at send time (see tagtinker_scene_transmit.c), so any
* BMP can target any tag - the entry's width/height are the *target* dims, not
* the source dims, and tx_stream_bmp_image samples the source pixels via
* nearest-neighbour as it streams.
*
* Filenames produced by web-image-prep are "<W>x<H>[_<label>].bmp"; the W/H
* prefix is optional and used only as a label hint. Legacy "_p<page>" suffix
* sets the default page. */
static bool tagtinker_parse_dropped_filename(
const char* name,
const char* expected_barcode,
uint16_t target_w,
uint16_t target_h,
TagTinkerSyncedImage* out) {
if(!name || !out) return false;
size_t name_len = strlen(name);
if(name_len < 5U) return false; /* min: "a.bmp" */
const char* ext = name + name_len - 4;
if(!((ext[0] == '.') &&
(ext[1] == 'b' || ext[1] == 'B') &&
(ext[2] == 'm' || ext[2] == 'M') &&
(ext[3] == 'p' || ext[3] == 'P'))) return false;
unsigned page = 1U;
int consumed = 0;
unsigned w_hint = 0U, h_hint = 0U;
bool has_hint = false;
/* Optional resolution prefix - we don't filter on it, the transmitter
* rescales any BMP to the target's dimensions automatically. The "_p<N>"
* suffix, when present, just sets the default page in image options. */
if(sscanf(name, "%ux%u%n", &w_hint, &h_hint, &consumed) >= 2) {
has_hint = true;
if((size_t)consumed < name_len && name[consumed] == '_' && name[consumed + 1] == 'p') {
unsigned parsed_page = 0U;
if(sscanf(name + consumed + 2, "%u", &parsed_page) == 1 && parsed_page <= 7U) {
page = parsed_page;
}
}
}
memset(out, 0, sizeof(*out));
if(expected_barcode) {
strncpy(out->barcode, expected_barcode, TAGTINKER_BC_LEN);
out->barcode[TAGTINKER_BC_LEN] = '\0';
}
/* Use the filename (without .bmp) as a stable job_id for display. */
size_t id_len = name_len - 4U;
if(id_len > TAGTINKER_SYNC_JOB_ID_LEN) id_len = TAGTINKER_SYNC_JOB_ID_LEN;
memcpy(out->job_id, name, id_len);
out->job_id[id_len] = '\0';
/* Stamp the target's resolution: the transmitter will rescale on the fly. */
out->width = target_w;
out->height = target_h;
out->page = (uint8_t)page;
/* Mark as resampled when the filename hints at a different source size,
* or when there's no hint at all (we can't tell, so flag it as foreign
* to be safe - it's a subtle "this might not be native" indicator). */
if(has_hint) {
out->resampled = (w_hint != target_w) || (h_hint != target_h);
} else {
out->resampled = true;
}
snprintf(out->image_path, sizeof(out->image_path), "%s/%s", TAGTINKER_DROPPED_DIR, name);
return true;
}
static void dropped_images_load(TagTinkerApp* app) {
if(app->selected_target < 0 || app->selected_target >= app->target_count) return;
const TagTinkerTarget* target = &app->targets[app->selected_target];
if(!tagtinker_target_supports_graphics(target)) return;
Storage* storage = furi_record_open(RECORD_STORAGE);
storage_common_mkdir(storage, TAGTINKER_DROPPED_DIR);
File* dir = storage_file_alloc(storage);
if(storage_dir_open(dir, TAGTINKER_DROPPED_DIR)) {
FileInfo info;
char name[128];
while(app->synced_image_count < TAGTINKER_MAX_SYNCED_IMAGES &&
storage_dir_read(dir, &info, name, (uint16_t)sizeof(name))) {
if(file_info_is_dir(&info)) continue;
TagTinkerSyncedImage entry;
if(!tagtinker_parse_dropped_filename(
name,
target->barcode,
target->profile.width,
target->profile.height,
&entry)) {
continue;
}
app->synced_images[app->synced_image_count++] = entry;
}
storage_dir_close(dir);
}
storage_file_free(dir);
furi_record_close(RECORD_STORAGE);
}
static uint8_t synced_image_menu_map[TAGTINKER_MAX_SYNCED_IMAGES];
/* Wide enough for "~ P9 " plus a 32-char job_id - the submenu module
* auto-marquees the selected row when it overflows the screen, so the full
* filename stays visible by selecting the entry. */
static char synced_image_labels[TAGTINKER_MAX_SYNCED_IMAGES][64];
static void synced_image_list_cb(void* ctx, uint32_t index) {
TagTinkerApp* app = ctx;
view_dispatcher_send_custom_event(app->view_dispatcher, index);
}
static char* synced_image_next_token(char** cursor) {
if(!cursor || !*cursor) return NULL;
char* token = *cursor;
char* sep = strchr(token, '|');
if(sep) {
*sep = '\0';
*cursor = sep + 1;
} else {
*cursor = NULL;
}
return token;
}
static void synced_images_load(TagTinkerApp* app) {
app->synced_image_count = 0;
if(app->selected_target < 0 || app->selected_target >= app->target_count) return;
Storage* storage = furi_record_open(RECORD_STORAGE);
File* file = storage_file_alloc(storage);
if(storage_file_open(file, APP_DATA_PATH("synced_images.txt"), FSAM_READ, FSOM_OPEN_EXISTING)) {
uint64_t size = storage_file_size(file);
if(size > 0 && size < 8192U) {
char* buf = malloc((size_t)size + 1U);
if(buf) {
uint16_t read = storage_file_read(file, buf, (uint16_t)size);
buf[read] = '\0';
char* line = buf;
while(line && *line && app->synced_image_count < TAGTINKER_MAX_SYNCED_IMAGES) {
char* nl = strchr(line, '\n');
if(nl) *nl = '\0';
if(*line) {
char* cursor = line;
char* job_id = synced_image_next_token(&cursor);
char* barcode = synced_image_next_token(&cursor);
char* width = synced_image_next_token(&cursor);
char* height = synced_image_next_token(&cursor);
char* page = synced_image_next_token(&cursor);
char* path = synced_image_next_token(&cursor);
if(job_id && barcode && width && height && page && path &&
strcmp(barcode, app->targets[app->selected_target].barcode) == 0 &&
storage_common_exists(storage, path)) {
TagTinkerSyncedImage* image =
&app->synced_images[app->synced_image_count++];
strncpy(image->job_id, job_id, TAGTINKER_SYNC_JOB_ID_LEN);
image->job_id[TAGTINKER_SYNC_JOB_ID_LEN] = '\0';
strncpy(image->barcode, barcode, TAGTINKER_BC_LEN);
image->barcode[TAGTINKER_BC_LEN] = '\0';
image->width = (uint16_t)atoi(width);
image->height = (uint16_t)atoi(height);
image->page = (uint8_t)atoi(page);
strncpy(image->image_path, path, TAGTINKER_IMAGE_PATH_LEN);
image->image_path[TAGTINKER_IMAGE_PATH_LEN] = '\0';
}
}
line = nl ? (nl + 1) : NULL;
}
free(buf);
}
}
storage_file_close(file);
}
storage_file_free(file);
furi_record_close(RECORD_STORAGE);
}
void tagtinker_scene_synced_image_list_on_enter(void* ctx) {
TagTinkerApp* app = ctx;
synced_images_load(app);
dropped_images_load(app);
submenu_reset(app->submenu);
submenu_set_header(app->submenu, "Set Image");
if(app->synced_image_count == 0) {
submenu_add_item(app->submenu, "No matching BMPs", 0, synced_image_list_cb, app);
submenu_add_item(app->submenu, "Drop into apps_data/", 0, synced_image_list_cb, app);
submenu_add_item(app->submenu, " tagtinker/dropped/", 0, synced_image_list_cb, app);
submenu_add_item(app->submenu, "Use Image Prep page", 0, synced_image_list_cb, app);
} else {
uint8_t menu_idx = 0;
for(int16_t i = (int16_t)app->synced_image_count - 1; i >= 0; i--) {
const TagTinkerSyncedImage* image = &app->synced_images[i];
/* "~" prefix subtly marks BMPs that aren't native to this tag's
* resolution (the FAP rescales them at TX time). The full job_id
* is appended so the submenu's auto-marquee can scroll long
* filenames sideways when the row is selected. */
snprintf(
synced_image_labels[menu_idx],
sizeof(synced_image_labels[menu_idx]),
"%sP%u %s",
image->resampled ? "~ " : "",
image->page,
image->job_id);
synced_image_menu_map[menu_idx] = (uint8_t)i;
submenu_add_item(
app->submenu,
synced_image_labels[menu_idx],
EVT_SYNCED_IMAGE_BASE + menu_idx,
synced_image_list_cb,
app);
menu_idx++;
}
}
view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewSubmenu);
}
bool tagtinker_scene_synced_image_list_on_event(void* ctx, SceneManagerEvent event) {
TagTinkerApp* app = ctx;
if(event.type != SceneManagerEventTypeCustom) return false;
if(event.event < EVT_SYNCED_IMAGE_BASE) return true;
uint32_t menu_idx = event.event - EVT_SYNCED_IMAGE_BASE;
if(menu_idx >= app->synced_image_count) return true;
if(app->selected_target < 0 || app->selected_target >= app->target_count) return true;
TagTinkerSyncedImage* image = &app->synced_images[synced_image_menu_map[menu_idx]];
TagTinkerTarget* target = &app->targets[app->selected_target];
app->img_page = image->page;
app->draw_x = 0;
app->draw_y = 0;
app->color_clear = false;
tagtinker_prepare_bmp_tx(
app, target->plid, image->image_path, image->width, image->height, image->page);
scene_manager_next_scene(app->scene_manager, TagTinkerSceneImageOptions);
return true;
}
void tagtinker_scene_synced_image_list_on_exit(void* ctx) {
TagTinkerApp* app = ctx;
submenu_reset(app->submenu);
}
@@ -0,0 +1,159 @@
/*
* Target actions scene.
*/
#include "../tagtinker_app.h"
static void target_actions_cb(void* ctx, uint32_t index) {
TagTinkerApp* app = ctx;
view_dispatcher_send_custom_event(app->view_dispatcher, index);
}
static bool confirm_target_action(TagTinkerApp* app, const char* header, const char* body, const char* action) {
if(!app || !header || !body || !action) return false;
DialogMessage* message = dialog_message_alloc();
dialog_message_set_header(message, header, 64, 2, AlignCenter, AlignTop);
dialog_message_set_text(message, body, 64, 18, AlignCenter, AlignTop);
dialog_message_set_buttons(message, "Back", NULL, action);
DialogMessageButton button = dialog_message_show(app->dialogs, message);
dialog_message_free(message);
return button == DialogMessageButtonRight;
}
static void show_target_details(TagTinkerApp* app, const TagTinkerTarget* target) {
if(!target) return;
text_box_reset(app->text_box);
text_box_set_font(app->text_box, TextBoxFontText);
text_box_set_focus(app->text_box, TextBoxFocusStart);
static char details_buf[256];
snprintf(
details_buf,
sizeof(details_buf),
"--- Tag Info ---\n"
"Model: %s\n"
"Type: %u (%s)\n"
"Size: %ux%u\n"
"Color: %s\n"
"Barcode:\n%s",
target->profile.model_name ? target->profile.model_name : "Unknown",
target->profile.type_code,
tagtinker_profile_kind_label(target->profile.kind),
target->profile.width,
target->profile.height,
tagtinker_profile_color_label(target->profile.color),
target->barcode);
text_box_set_text(app->text_box, details_buf);
scene_manager_next_scene(app->scene_manager, TagTinkerSceneTextBox);
}
void tagtinker_scene_target_actions_on_enter(void* ctx) {
TagTinkerApp* app = ctx;
TagTinkerTarget* target = (app->selected_target >= 0) ? &app->targets[app->selected_target] : NULL;
bool allow_graphics = tagtinker_target_supports_graphics(target);
submenu_reset(app->submenu);
char header[24];
snprintf(
header,
sizeof(header),
"%s",
(target && target->name[0]) ? target->name : "Target");
submenu_set_header(app->submenu, header);
submenu_add_item(app->submenu, "Show Tag Info", TagTinkerTargetDetails, target_actions_cb, app);
submenu_add_item(app->submenu, "Rename Tag", TagTinkerTargetRename, target_actions_cb, app);
if(allow_graphics) {
submenu_add_item(app->submenu, "Set Text", TagTinkerTargetPushText, target_actions_cb, app);
submenu_add_item(app->submenu, "Set Image", TagTinkerTargetPushSyncedImage, target_actions_cb, app);
submenu_add_item(app->submenu, "WiFi Plugins", TagTinkerTargetWifiPlugins, target_actions_cb, app);
}
submenu_add_item(app->submenu, "LED Test", TagTinkerTargetPingFlash, target_actions_cb, app);
submenu_add_item(app->submenu, "Delete Tag", TagTinkerTargetDeleteTag, target_actions_cb, app);
view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewSubmenu);
}
bool tagtinker_scene_target_actions_on_event(void* ctx, SceneManagerEvent event) {
TagTinkerApp* app = ctx;
if(event.type != SceneManagerEventTypeCustom) return false;
switch(event.event) {
case TagTinkerTargetDetails:
show_target_details(app, &app->targets[app->selected_target]);
return true;
case TagTinkerTargetRename:
scene_manager_set_scene_state(
app->scene_manager, TagTinkerSceneTextInput, TagTinkerTextInputRenameTarget);
scene_manager_next_scene(app->scene_manager, TagTinkerSceneTextInput);
return true;
case TagTinkerTargetPushText:
if(!tagtinker_target_supports_graphics(&app->targets[app->selected_target])) return true;
scene_manager_next_scene(app->scene_manager, TagTinkerScenePresetList);
return true;
case TagTinkerTargetPushSyncedImage:
if(!tagtinker_target_supports_graphics(&app->targets[app->selected_target])) return true;
scene_manager_next_scene(app->scene_manager, TagTinkerSceneSyncedImageList);
return true;
case TagTinkerTargetWifiPlugins:
if(!tagtinker_target_supports_graphics(&app->targets[app->selected_target])) return true;
scene_manager_next_scene(app->scene_manager, TagTinkerSceneWifiPlugins);
return true;
case TagTinkerTargetPingFlash:
{
TagTinkerTarget* target = &app->targets[app->selected_target];
app->frame_seq_count = 2;
app->frame_sequence = malloc(sizeof(uint8_t*) * 2);
app->frame_lengths = malloc(sizeof(size_t) * 2);
app->frame_repeats = malloc(sizeof(uint16_t) * 2);
app->frame_sequence[0] = malloc(TAGTINKER_MAX_FRAME_SIZE);
app->frame_lengths[0] = tagtinker_make_ping_frame(app->frame_sequence[0], target->plid);
app->frame_repeats[0] = 160;
app->frame_sequence[1] = malloc(TAGTINKER_MAX_FRAME_SIZE);
/* 0x06 command, 0x49 payload (LED flash, page 1, no forever), 0x0005 duration */
const uint8_t blink_payload[6] = {0x06, 0x49, 0x00, 0x00, 0x00, 0x05};
app->frame_lengths[1] = tagtinker_make_addressed_frame(
app->frame_sequence[1], target->plid, blink_payload, 6);
app->frame_repeats[1] = 80;
memcpy(app->frame_buf, app->frame_sequence[0], app->frame_lengths[0]);
app->frame_len = app->frame_lengths[0];
app->tx_spam = false;
scene_manager_next_scene(app->scene_manager, TagTinkerSceneTransmit);
}
return true;
case TagTinkerTargetDeleteTag:
{
if(app->selected_target < 0 || app->selected_target >= app->target_count) {
return true;
}
TagTinkerTarget* target = &app->targets[app->selected_target];
char body[96];
snprintf(body, sizeof(body), "Delete %s and its\nsaved images?", target->name);
if(!confirm_target_action(app, "Delete Tag", body, "Delete")) {
return true;
}
tagtinker_delete_target(app, (uint8_t)app->selected_target);
scene_manager_search_and_switch_to_previous_scene(
app->scene_manager, TagTinkerSceneTargetMenu);
}
return true;
}
return false;
}
void tagtinker_scene_target_actions_on_exit(void* ctx) {
TagTinkerApp* app = ctx;
submenu_reset(app->submenu);
}
@@ -0,0 +1,71 @@
/*
* Saved tag menu - saved targets + add new
*/
#include "../tagtinker_app.h"
enum {
TargetMenuScanNfc = 99,
TargetMenuAddNew = 100,
};
static void target_menu_cb(void* ctx, uint32_t index) {
TagTinkerApp* app = ctx;
view_dispatcher_send_custom_event(app->view_dispatcher, index);
}
void tagtinker_scene_target_menu_on_enter(void* ctx) {
TagTinkerApp* app = ctx;
submenu_reset(app->submenu);
submenu_set_header(app->submenu, "Targeted Payloads");
/* Add new target */
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++) {
/* Use index as event id (0..15) */
submenu_add_item(
app->submenu,
app->targets[i].name[0] ? app->targets[i].name : app->targets[i].barcode,
i,
target_menu_cb,
app);
}
view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewSubmenu);
}
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(
app->scene_manager, TagTinkerSceneBarcodeInput, TagTinkerSceneTargetActions);
scene_manager_next_scene(app->scene_manager, TagTinkerSceneBarcodeInput);
return true;
}
/* Selected a saved target */
if(event.event < app->target_count) {
tagtinker_select_target(app, (uint8_t)event.event);
scene_manager_next_scene(app->scene_manager, TagTinkerSceneTargetActions);
return true;
}
return false;
}
void tagtinker_scene_target_menu_on_exit(void* ctx) {
TagTinkerApp* app = ctx;
submenu_reset(app->submenu);
}
@@ -0,0 +1,22 @@
#include "../tagtinker_app.h"
void tagtinker_scene_text_box_on_enter(void* ctx) {
TagTinkerApp* app = ctx;
view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewTextBox);
}
bool tagtinker_scene_text_box_on_event(void* ctx, SceneManagerEvent event) {
TagTinkerApp* app = ctx;
bool handled = false;
if(event.type == SceneManagerEventTypeBack) {
handled = scene_manager_previous_scene(app->scene_manager);
}
return handled;
}
void tagtinker_scene_text_box_on_exit(void* ctx) {
TagTinkerApp* app = ctx;
text_box_reset(app->text_box);
}
@@ -0,0 +1,94 @@
/*
* Text input scene.
*/
#include "../tagtinker_app.h"
static void text_input_done_cb(void* ctx) {
TagTinkerApp* app = ctx;
view_dispatcher_send_custom_event(app->view_dispatcher, 0);
}
static void text_input_sanitize_name(char* value) {
if(!value) return;
for(char* p = value; *p; p++) {
if(*p == '|' || *p == '\r' || *p == '\n') {
*p = ' ';
}
}
}
void tagtinker_scene_text_input_on_enter(void* ctx) {
TagTinkerApp* app = ctx;
uint32_t mode = scene_manager_get_scene_state(app->scene_manager, TagTinkerSceneTextInput);
bool rename_target = mode == TagTinkerTextInputRenameTarget;
bool clear = mode == TagTinkerTextInputNewText;
if(rename_target) {
if(app->selected_target >= 0 && app->selected_target < app->target_count) {
strncpy(
app->text_input_buf,
app->targets[app->selected_target].name,
sizeof(app->text_input_buf) - 1U);
app->text_input_buf[sizeof(app->text_input_buf) - 1U] = '\0';
} else {
memset(app->text_input_buf, 0, sizeof(app->text_input_buf));
}
} else if(clear) {
memset(app->text_input_buf, 0, sizeof(app->text_input_buf));
scene_manager_set_scene_state(
app->scene_manager, TagTinkerSceneTextInput, TagTinkerTextInputKeepText);
}
text_input_reset(app->text_input);
text_input_set_header_text(app->text_input, rename_target ? "Target name:" : "Text to display:");
text_input_set_result_callback(
app->text_input,
text_input_done_cb,
app,
app->text_input_buf,
sizeof(app->text_input_buf),
clear && !rename_target);
view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewTextInput);
}
bool tagtinker_scene_text_input_on_event(void* ctx, SceneManagerEvent event) {
TagTinkerApp* app = ctx;
if(event.type != SceneManagerEventTypeCustom) return false;
uint32_t mode = scene_manager_get_scene_state(app->scene_manager, TagTinkerSceneTextInput);
if(mode == TagTinkerTextInputRenameTarget) {
if(app->selected_target >= 0 && app->selected_target < app->target_count) {
TagTinkerTarget* target = &app->targets[app->selected_target];
text_input_sanitize_name(app->text_input_buf);
if(strlen(app->text_input_buf) == 0U) {
tagtinker_target_set_default_name(app, target);
} else {
strncpy(target->name, app->text_input_buf, TAGTINKER_TARGET_NAME_LEN);
target->name[TAGTINKER_TARGET_NAME_LEN] = '\0';
}
tagtinker_targets_save(app);
}
scene_manager_search_and_switch_to_previous_scene(
app->scene_manager, TagTinkerSceneTargetActions);
return true;
}
if(strlen(app->text_input_buf) == 0) {
scene_manager_search_and_switch_to_previous_scene(
app->scene_manager, TagTinkerSceneTargetActions);
return true;
}
/* Configure settings (Add Preset flow) */
scene_manager_next_scene(app->scene_manager, TagTinkerSceneSizePicker);
return true;
}
void tagtinker_scene_text_input_on_exit(void* ctx) {
TagTinkerApp* app = ctx;
text_input_reset(app->text_input);
}
@@ -0,0 +1,832 @@
/**
* Transmit Scene
* Displays cool animations while transmitting in a background thread.
*/
#include "../tagtinker_app.h"
#include "../views/tagtinker_font.h"
#include <furi_hal.h>
#include <storage/storage.h>
typedef struct {
TagTinkerApp* app;
uint32_t tick;
bool completed;
bool ok;
} TxViewModel;
typedef struct {
uint32_t data_offset;
uint32_t row_stride;
uint16_t width;
uint16_t height;
uint16_t bpp;
bool top_down;
} TxBmpInfo;
/* Full-job threshold: if the whole image fits in memory, render it in one
* shot instead of streaming in chunks. 49152 covers 208×112×2 = 46592
* pixels (the most common color DM images). */
#define TX_FULL_JOB_PIXEL_LIMIT 49152U
static uint16_t tx_pick_chunk_height(uint16_t width, uint16_t height, bool second_plane);
static void tx_debug_log(const char* fmt, ...) {
UNUSED(fmt);
}
static uint16_t tx_apply_signal_mode(const TagTinkerApp* app, uint16_t repeats) {
UNUSED(app);
return repeats & 0x7FFFU;
}
static TagTinkerTagColor tx_target_color(const TagTinkerApp* app) {
if(app->selected_target < 0 || app->selected_target >= app->target_count) {
return TagTinkerTagColorMono;
}
return app->targets[app->selected_target].profile.color;
}
static bool tx_send_frame(TagTinkerApp* app, const uint8_t* frame, size_t len, uint16_t repeats) {
if(!app->tx_active) return false;
return tagtinker_ir_transmit(frame, len, tx_apply_signal_mode(app, repeats), 1);
}
static bool tx_send_ping(TagTinkerApp* app, const uint8_t plid[4]) {
uint8_t frame[TAGTINKER_MAX_FRAME_SIZE];
size_t len = tagtinker_make_ping_frame(frame, plid);
return tx_send_frame(app, frame, len, 80);
}
static bool tx_send_refresh(TagTinkerApp* app, const uint8_t plid[4]) {
uint8_t frame[TAGTINKER_MAX_FRAME_SIZE];
size_t len = tagtinker_make_refresh_frame(frame, plid);
return tx_send_frame(app, frame, len, 20);
}
static bool tx_should_send_full_job(uint16_t width, uint16_t height, bool second_plane) {
size_t pixel_count = (size_t)width * height;
if(second_plane) pixel_count *= 2U;
return pixel_count <= TX_FULL_JOB_PIXEL_LIMIT;
}
static bool tx_send_image_start(
TagTinkerApp* app,
const uint8_t plid[4],
uint16_t byte_count,
uint8_t comp_type,
uint8_t page,
uint16_t width,
uint16_t height,
uint16_t pos_x,
uint16_t pos_y) {
uint8_t frame[TAGTINKER_MAX_FRAME_SIZE];
size_t len = tagtinker_make_image_param_frame(
frame, plid, byte_count, comp_type, page, width, height, pos_x, pos_y);
return tx_send_frame(app, frame, len, 15);
}
static bool tx_send_payload_frames(
TagTinkerApp* app,
const uint8_t plid[4],
const TagTinkerImagePayload* payload,
uint8_t page,
uint16_t width,
uint16_t height,
uint16_t pos_x,
uint16_t pos_y) {
uint8_t frame[TAGTINKER_MAX_FRAME_SIZE];
bool ok = tx_send_image_start(
app,
plid,
(uint16_t)payload->byte_count,
payload->comp_type,
page,
width,
height,
pos_x,
pos_y);
if(ok) furi_delay_ms(50);
size_t frame_count = payload->byte_count / TAGTINKER_IMAGE_DATA_BYTES_PER_FRAME;
for(size_t i = 0; ok && i < frame_count; i++) {
size_t len = tagtinker_make_image_data_frame(
frame,
plid,
(uint16_t)i,
&payload->data[i * TAGTINKER_IMAGE_DATA_BYTES_PER_FRAME]);
ok = tx_send_frame(app, frame, len, app->data_frame_repeats);
/* Short delay to avoid tag overflow */
if(ok && ((i + 1U) % 32U) == 0U && (i + 1U) < frame_count) {
furi_delay_ms(1);
}
}
return ok;
}
static bool tx_send_image_chunk(
TagTinkerApp* app,
const uint8_t plid[4],
const uint8_t* primary_pixels,
const uint8_t* secondary_pixels,
uint16_t width,
uint16_t height,
uint8_t page,
uint16_t pos_x,
uint16_t pos_y) {
TagTinkerImagePayload payload;
size_t pixel_count = (size_t)width * height;
if(!tagtinker_encode_planes_payload(
primary_pixels,
secondary_pixels,
pixel_count,
app->compression_mode,
&payload)) {
return false;
}
bool ok = tx_send_payload_frames(app, plid, &payload, page, width, height, pos_x, pos_y);
tagtinker_free_image_payload(&payload);
return ok;
}
static uint32_t tx_chunk_settle_delay_ms(uint16_t width, uint16_t height, bool color_clear) {
/* Tag needs time to process each chunk before accepting the next one.
* Keep this as short as possible while remaining reliable. */
UNUSED(color_clear);
size_t work_pixels = (size_t)width * height;
uint32_t delay_ms = 500U + (uint32_t)(work_pixels / 20U);
if(delay_ms < 800U) delay_ms = 800U;
if(delay_ms > 2000U) delay_ms = 2000U;
return delay_ms;
}
static uint16_t tx_pick_chunk_height(uint16_t width, uint16_t height, bool second_plane) {
UNUSED(second_plane);
if(width == 0U || height == 0U) return 1U;
/*
* Keep each plane buffer 8 KB so two planes + encode overhead fits
* in the Flipper's heap even right after BLE teardown.
* 8 KB (down from 12 KB) leaves extra headroom for the encoder's
* internal allocations and any lingering BLE buffers.
*/
size_t per_plane_budget = 8192U; /* 8 KB */
uint16_t chunk_h = (uint16_t)(per_plane_budget / width);
if(chunk_h == 0U) chunk_h = 1U;
if(chunk_h > height) chunk_h = height;
/* Round down to 8-row boundary for alignment, but keep at least 1 */
if(chunk_h >= 16U) {
chunk_h = (uint16_t)(chunk_h & ~7U);
if(chunk_h == 0U) chunk_h = 8U;
}
if(chunk_h > height) chunk_h = height;
if(chunk_h == 0U) chunk_h = 1U;
return chunk_h;
}
static bool tx_send_full_payload(
TagTinkerApp* app,
const uint8_t plid[4],
const TagTinkerImagePayload* payload,
uint8_t page,
uint16_t width,
uint16_t height,
uint16_t pos_x,
uint16_t pos_y) {
bool ok = tx_send_ping(app, plid);
if(ok) furi_delay_ms(50);
if(ok) ok = tx_send_payload_frames(app, plid, payload, page, width, height, pos_x, pos_y);
if(ok) furi_delay_ms(50);
if(ok) ok = tx_send_refresh(app, plid);
return ok;
}
static bool tx_send_full_text_image(TagTinkerApp* app) {
const TagTinkerImageTxJob* job = &app->image_tx_job;
const TagTinkerTarget* target =
(app->selected_target >= 0) ? &app->targets[app->selected_target] : NULL;
bool accent_capable = tagtinker_target_supports_accent(target);
bool accent_text = accent_capable && app->color_clear;
bool use_second_plane = accent_capable || app->color_clear;
size_t pixel_count = (size_t)job->width * job->height;
TagTinkerTagColor accent_color = tx_target_color(app);
if(!tx_should_send_full_job(job->width, job->height, use_second_plane)) {
return false;
}
uint8_t* primary = malloc(pixel_count);
uint8_t* secondary = use_second_plane ? malloc(pixel_count) : NULL;
if(!primary || (use_second_plane && !secondary)) {
free(primary);
free(secondary);
return false;
}
if(accent_text) {
uint8_t bg_primary = app->invert_text ? 0 : 1;
uint8_t fg_primary = (accent_color == TagTinkerTagColorYellow) ? 0 : 1;
render_text_ex(primary, job->width, job->height, app->text_input_buf, bg_primary, fg_primary, app->text_padding_pct);
render_text_ex(secondary, job->width, job->height, app->text_input_buf, 1, 0, app->text_padding_pct);
} else {
render_text_ex(
primary,
job->width,
job->height,
app->text_input_buf,
app->invert_text ? 0 : 1,
app->invert_text ? 1 : 0,
app->text_padding_pct);
if(secondary) memset(secondary, 1, pixel_count);
}
TagTinkerImagePayload payload;
bool ok = tagtinker_encode_planes_payload(
primary, secondary, pixel_count, app->compression_mode, &payload);
free(primary);
free(secondary);
if(!ok) return false;
ok = tx_send_full_payload(
app,
job->plid,
&payload,
job->page,
job->width,
job->height,
job->pos_x,
job->pos_y);
tagtinker_free_image_payload(&payload);
return ok;
}
static bool tx_stream_text_image(TagTinkerApp* app) {
if(tx_send_full_text_image(app)) {
return true;
}
const TagTinkerImageTxJob* job = &app->image_tx_job;
const TagTinkerTarget* target =
(app->selected_target >= 0) ? &app->targets[app->selected_target] : NULL;
bool accent_capable = tagtinker_target_supports_accent(target);
bool accent_text = accent_capable && app->color_clear;
bool use_second_plane = accent_capable || app->color_clear;
TagTinkerTagColor accent_color = tx_target_color(app);
uint16_t chunk_h = tx_pick_chunk_height(job->width, job->height, use_second_plane);
uint8_t* primary = malloc((size_t)job->width * chunk_h);
uint8_t* secondary = use_second_plane ? malloc((size_t)job->width * chunk_h) : NULL;
if(!primary || (use_second_plane && !secondary)) {
free(primary);
free(secondary);
return false;
}
bool ok = true;
for(uint16_t y = 0; ok && y < job->height; y = (uint16_t)(y + chunk_h)) {
uint16_t actual_h = job->height - y;
if(actual_h > chunk_h) actual_h = chunk_h;
if(accent_text) {
uint8_t bg_primary = app->invert_text ? 0 : 1;
uint8_t fg_primary = (accent_color == TagTinkerTagColorYellow) ? 0 : 1;
render_text_region_ex(
primary,
job->width,
job->height,
y,
actual_h,
app->text_input_buf,
bg_primary,
fg_primary,
app->text_padding_pct);
render_text_region_ex(
secondary,
job->width,
job->height,
y,
actual_h,
app->text_input_buf,
1,
0,
app->text_padding_pct);
} else {
render_text_region_ex(
primary,
job->width,
job->height,
y,
actual_h,
app->text_input_buf,
app->invert_text ? 0 : 1,
app->invert_text ? 1 : 0,
app->text_padding_pct);
if(secondary) memset(secondary, 1, (size_t)job->width * actual_h);
}
ok = tx_send_ping(app, job->plid);
if(ok) furi_delay_ms(10);
ok = tx_send_image_chunk(
app,
job->plid,
primary,
secondary,
job->width,
actual_h,
job->page,
job->pos_x,
(uint16_t)(job->pos_y + y));
if(ok) furi_delay_ms(10);
if(ok) ok = tx_send_refresh(app, job->plid);
if(ok && (uint16_t)(y + actual_h) < job->height) {
furi_delay_ms(tx_chunk_settle_delay_ms(job->width, actual_h, use_second_plane));
}
}
free(primary);
free(secondary);
return ok;
}
static bool tx_bmp_open(const char* path, File* file, TxBmpInfo* info) {
if(!storage_file_open(file, path, FSAM_READ, FSOM_OPEN_EXISTING)) return false;
uint8_t header[54];
if(storage_file_read(file, header, sizeof(header)) != sizeof(header)) return false;
if(header[0] != 'B' || header[1] != 'M') return false;
uint16_t bpp = header[28] | (header[29] << 8);
if(!(bpp == 1 || bpp == 2 || bpp == 24 || bpp == 32)) return false;
info->bpp = bpp;
int32_t bmp_h = header[22] | (header[23] << 8) | (header[24] << 16) | (header[25] << 24);
info->width = (uint16_t)(header[18] | (header[19] << 8) | (header[20] << 16) | (header[21] << 24));
info->top_down = false;
if(bmp_h < 0) {
info->top_down = true;
bmp_h = -bmp_h;
}
info->height = (uint16_t)bmp_h;
info->data_offset = header[10] | (header[11] << 8) | (header[12] << 16) | (header[13] << 24);
if(info->bpp == 1 || info->bpp == 2) {
info->row_stride = ((info->width + 31U) / 32U) * 4U;
} else if(info->bpp == 24) {
info->row_stride = ((info->width * 3U) + 3U) & ~3U;
} else {
info->row_stride = info->width * 4U;
}
return true;
}
/*
* Zero-allocation streaming BMP transmitter with RLE compression.
*
* Two-pass approach:
* Pass 1: Read all pixels from BMP, count RLE compressed bit length.
* Pass 2: Re-read pixels, RLE-encode on-the-fly into IR data frames.
*
* Total stack usage: ~200 bytes. Zero heap allocation.
*
* Flow: PING -> PARAM (full image dims, RLE) -> DATA frames (streamed) -> REFRESH
*/
/* Elias gamma bit length for a run count */
static inline size_t rle_run_bits(uint32_t count) {
uint8_t n = 0;
uint32_t v = count;
while(v) { n++; v >>= 1; }
return (size_t)(n * 2U) - 1U;
}
static inline uint8_t bmp_read_pixel(const uint8_t* row_buf, uint16_t x) {
uint8_t byte = row_buf[x / 8U];
uint8_t bit = (byte >> (7U - (x % 8U))) & 1U;
return bit ? 0U : 1U;
}
/* Map an output row index to a source row using nearest-neighbour rescaling,
* then read that source row (handling top-down vs bottom-up BMPs and the
* stacked-plane layout used by 2bpp accent BMPs). The transmitter calls this
* once per output row, with a small cache so we don't re-seek the file when
* an upscale maps several output rows to the same source row. */
static inline uint16_t bmp_map_y(uint16_t out_y, uint16_t tx_h, uint16_t src_h) {
if(tx_h == 0U || src_h == 0U) return 0U;
uint32_t y = (uint32_t)out_y * (uint32_t)src_h / (uint32_t)tx_h;
if(y >= src_h) y = src_h - 1U;
return (uint16_t)y;
}
static inline uint16_t bmp_map_x(uint16_t out_x, uint16_t tx_w, uint16_t src_w) {
if(tx_w == 0U || src_w == 0U) return 0U;
uint32_t x = (uint32_t)out_x * (uint32_t)src_w / (uint32_t)tx_w;
if(x >= src_w) x = src_w - 1U;
return (uint16_t)x;
}
static inline bool bmp_read_row_at(
File* file, const TxBmpInfo* info,
uint16_t src_y, uint16_t plane_offset_rows,
uint8_t* row_buf) {
uint16_t actual_row =
info->top_down ? src_y : (uint16_t)(info->height - 1U - src_y);
uint32_t off = info->data_offset +
((uint32_t)actual_row + (uint32_t)plane_offset_rows) * info->row_stride;
storage_file_seek(file, off, true);
return storage_file_read(file, row_buf, info->row_stride) == info->row_stride;
}
#define BMP_FETCH_ROW(out_y, plane_off) do { \
uint16_t _src_y = bmp_map_y((out_y), tx_height, info.height); \
if(_src_y != cached_src_y) { \
if(!bmp_read_row_at(file, &info, _src_y, (plane_off), row_buf)) { ok = false; break; } \
cached_src_y = _src_y; \
} \
} while(0)
static bool tx_stream_bmp_image(TagTinkerApp* app) {
const TagTinkerImageTxJob* job = &app->image_tx_job;
tx_debug_log("BMP TX: path=%s w=%u h=%u page=%u",
job->image_path, job->width, job->height, job->page);
Storage* storage = furi_record_open(RECORD_STORAGE);
File* file = storage_file_alloc(storage);
TxBmpInfo info = {0};
bool ok = tx_bmp_open(job->image_path, file, &info);
tx_debug_log("bmp_open=%d bmp_w=%u bmp_h=%u bpp=%u off=%lu",
ok, info.width, info.height, info.bpp, (unsigned long)info.data_offset);
if(!ok) {
storage_file_close(file);
storage_file_free(file);
furi_record_close(RECORD_STORAGE);
return false;
}
/* Output dims come from the target's profile; source dims come from the
* BMP file. The streaming pipeline below rescales source -> target with
* nearest-neighbour as it reads, so any BMP can drive any tag. */
uint16_t tx_width = (job->width > 0U) ? job->width : info.width;
uint16_t tx_height = (job->height > 0U) ? job->height : info.height;
TagTinkerTagColor accent_color = tx_target_color(app);
bool accent_capable =
accent_color == TagTinkerTagColorRed || accent_color == TagTinkerTagColorYellow;
bool use_second_plane = app->color_clear || accent_capable;
bool has_secondary_in_bmp = (info.bpp == 2);
/* Plane offset (in source rows) of the secondary plane in 2bpp BMPs. */
uint16_t plane2_off_rows = info.height;
UNUSED(accent_color);
/* Source row stride is bounded by max profile width (800 px) -> 104 B,
* round up generously to 128 to absorb any future profile additions. */
uint8_t row_buf[128];
uint16_t cached_src_y = UINT16_MAX;
/* ---- PASS 1: Count RLE compressed bit length ---- */
size_t rle_bits = 0;
uint8_t run_pixel = 0;
uint32_t run_count = 0;
bool first = true;
cached_src_y = UINT16_MAX;
for(uint16_t y = 0; ok && y < tx_height; y++) {
BMP_FETCH_ROW(y, 0U);
for(uint16_t x = 0; x < tx_width; x++) {
uint8_t pix = bmp_read_pixel(row_buf, bmp_map_x(x, tx_width, info.width));
if(first) { rle_bits = 1; run_pixel = pix; run_count = 1; first = false; }
else if(pix == run_pixel) { run_count++; }
else { rle_bits += rle_run_bits(run_count); run_pixel = pix; run_count = 1; }
}
}
if(ok && use_second_plane) {
if(has_secondary_in_bmp) {
cached_src_y = UINT16_MAX;
for(uint16_t y = 0; ok && y < tx_height; y++) {
BMP_FETCH_ROW(y, plane2_off_rows);
for(uint16_t x = 0; x < tx_width; x++) {
uint8_t pix = bmp_read_pixel(row_buf, bmp_map_x(x, tx_width, info.width));
if(first) { rle_bits = 1; run_pixel = pix; run_count = 1; first = false; }
else if(pix == run_pixel) { run_count++; }
else { rle_bits += rle_run_bits(run_count); run_pixel = pix; run_count = 1; }
}
}
} else {
uint32_t count = (uint32_t)tx_width * tx_height;
if(first) { rle_bits = 1; run_pixel = 1; run_count = count; first = false; }
else if(1U == run_pixel) { run_count += count; }
else { rle_bits += rle_run_bits(run_count); run_pixel = 1; run_count = count; }
}
}
if(run_count > 0) rle_bits += rle_run_bits(run_count);
if(!ok) {
storage_file_close(file);
storage_file_free(file);
furi_record_close(RECORD_STORAGE);
return false;
}
tx_debug_log("STREAM RLE: bits=%u", (unsigned)rle_bits);
uint32_t raw_bits = (uint32_t)tx_width * tx_height;
if(use_second_plane) raw_bits *= 2U;
bool use_compressed = (app->compression_mode == TagTinkerCompressionRle) ||
(app->compression_mode == TagTinkerCompressionAuto && rle_bits > 0U && rle_bits < raw_bits);
uint32_t target_bits = use_compressed ? (uint32_t)rle_bits : raw_bits;
uint32_t padded_bytes = (target_bits + 7U) / 8U;
padded_bytes += (TAGTINKER_IMAGE_DATA_BYTES_PER_FRAME - (padded_bytes % TAGTINKER_IMAGE_DATA_BYTES_PER_FRAME)) % TAGTINKER_IMAGE_DATA_BYTES_PER_FRAME;
uint8_t* encoded = calloc(padded_bytes, 1);
if(!encoded) {
tx_debug_log("RLE/RAW encode malloc fail");
storage_file_close(file);
storage_file_free(file);
furi_record_close(RECORD_STORAGE);
return false;
}
if(use_compressed) {
/* ---- PASS 2: RLE encode into a small heap buffer ---- */
size_t enc_bit_pos = 0;
#define ENC_BIT(b) do { \
if(b) encoded[enc_bit_pos / 8U] |= (1U << (7U - (enc_bit_pos % 8U))); \
enc_bit_pos++; \
} while(0)
#define ENC_RUN(cnt) do { \
uint8_t _bits[32]; int _n = 0; uint32_t _v = (cnt); \
while(_v) { _bits[_n++] = _v & 1U; _v >>= 1; } \
for(int _i = 0; _i < _n/2; _i++) { uint8_t _t = _bits[_i]; _bits[_i] = _bits[_n-1-_i]; _bits[_n-1-_i] = _t; } \
for(int _i = 1; _i < _n; _i++) ENC_BIT(0U); \
for(int _i = 0; _i < _n; _i++) ENC_BIT(_bits[_i]); \
} while(0)
run_pixel = 0; run_count = 0; first = true;
cached_src_y = UINT16_MAX;
for(uint16_t y = 0; ok && y < tx_height; y++) {
BMP_FETCH_ROW(y, 0U);
for(uint16_t x = 0; x < tx_width; x++) {
uint8_t pix = bmp_read_pixel(row_buf, bmp_map_x(x, tx_width, info.width));
if(first) { ENC_BIT(pix); run_pixel = pix; run_count = 1; first = false; }
else if(pix == run_pixel) { run_count++; }
else { ENC_RUN(run_count); run_pixel = pix; run_count = 1; }
}
}
if(ok && use_second_plane) {
if(has_secondary_in_bmp) {
cached_src_y = UINT16_MAX;
for(uint16_t y = 0; ok && y < tx_height; y++) {
BMP_FETCH_ROW(y, plane2_off_rows);
for(uint16_t x = 0; x < tx_width; x++) {
uint8_t pix = bmp_read_pixel(row_buf, bmp_map_x(x, tx_width, info.width));
if(first) { ENC_BIT(pix); run_pixel = pix; run_count = 1; first = false; }
else if(pix == run_pixel) { run_count++; }
else { ENC_RUN(run_count); run_pixel = pix; run_count = 1; }
}
}
} else {
uint32_t count = (uint32_t)tx_width * tx_height;
if(first) { ENC_BIT(1U); run_pixel = 1; run_count = count; first = false; }
else if(1U == run_pixel) { run_count += count; }
else { ENC_RUN(run_count); run_pixel = 1; run_count = count; }
}
}
if(run_count > 0) { ENC_RUN(run_count); }
#undef ENC_BIT
#undef ENC_RUN
} else {
/* ---- PASS 2: RAW encode into heap buffer ---- */
size_t bit_idx = 0;
cached_src_y = UINT16_MAX;
for(uint16_t y = 0; ok && y < tx_height; y++) {
BMP_FETCH_ROW(y, 0U);
for(uint16_t x = 0; x < tx_width; x++) {
uint8_t pix = bmp_read_pixel(row_buf, bmp_map_x(x, tx_width, info.width));
if(pix != 0) encoded[bit_idx / 8U] |= (1U << (7U - (bit_idx % 8U)));
bit_idx++;
}
}
if(ok && use_second_plane) {
if(has_secondary_in_bmp) {
cached_src_y = UINT16_MAX;
for(uint16_t y = 0; ok && y < tx_height; y++) {
BMP_FETCH_ROW(y, plane2_off_rows);
for(uint16_t x = 0; x < tx_width; x++) {
uint8_t pix = bmp_read_pixel(row_buf, bmp_map_x(x, tx_width, info.width));
if(pix != 0) encoded[bit_idx / 8U] |= (1U << (7U - (bit_idx % 8U)));
bit_idx++;
}
}
} else {
uint32_t count = (uint32_t)tx_width * tx_height;
for(uint32_t i = 0; i < count; i++) {
encoded[bit_idx / 8U] |= (1U << (7U - (bit_idx % 8U)));
bit_idx++;
}
}
}
}
storage_file_close(file);
storage_file_free(file);
furi_record_close(RECORD_STORAGE);
if(!ok) { free(encoded); return false; }
/* Transmit encoded buffer */
TagTinkerImagePayload payload;
payload.data = encoded;
payload.byte_count = padded_bytes;
payload.comp_type = use_compressed ? 2U : 0U;
ok = tx_send_full_payload(
app, job->plid, &payload, job->page,
tx_width, tx_height, job->pos_x, job->pos_y);
free(encoded);
return ok;
}
static int32_t tx_thread_callback(void* context) {
TagTinkerApp* app = context;
bool ok = true;
/* Boost priority for IR timing */
furi_thread_set_current_priority(FuriThreadPriorityHighest);
tagtinker_ir_init();
/* Let OS settle (especially BLE teardown) before IR blasting */
if(app->image_tx_job.mode == TagTinkerTxModeBmpImage || app->image_tx_job.mode == TagTinkerTxModeTextImage) {
furi_delay_ms(500);
}
do {
if(app->image_tx_job.mode == TagTinkerTxModeTextImage) {
ok = tx_stream_text_image(app);
} else if(app->image_tx_job.mode == TagTinkerTxModeBmpImage) {
ok = tx_stream_bmp_image(app);
} else if(app->frame_seq_count > 0) {
for(size_t i = 0; i < app->frame_seq_count; i++) {
if(!app->tx_active) { ok = false; break; }
if(i > 0) furi_delay_ms(20);
ok = tagtinker_ir_transmit(
app->frame_sequence[i], app->frame_lengths[i],
tx_apply_signal_mode(app, app->frame_repeats[i]), 10);
if(!ok) break;
}
} else {
ok = tagtinker_ir_transmit(
app->frame_buf, app->frame_len, tx_apply_signal_mode(app, app->repeats), 10);
}
if(app->tx_spam && app->tx_active) {
furi_delay_ms(50);
}
} while(app->tx_spam && app->tx_active);
app->tx_active = false;
/* Use event payload to securely pass result instead of querying running thread! */
view_dispatcher_send_custom_event(app->view_dispatcher, 101 + (ok ? 0 : 1));
return 0;
}
static void transmit_draw_cb(Canvas* canvas, void* _model) {
TxViewModel* model = _model;
TagTinkerApp* app = model->app;
uint32_t t = model->tick;
/* Static title - large bold "Tinkering Tag" with the instruction below.
* Kept text-only (no busy animation) because the IR blaster runs at the
* highest thread priority and starves the GUI; trying to animate during
* transmission either looks janky or steals CPU from the IR timing. */
canvas_set_font(canvas, FontPrimary);
canvas_draw_str_aligned(canvas, 64, 12, AlignCenter, AlignTop, "Tinkering Tag");
canvas_set_font(canvas, FontSecondary);
if(app->tx_active) {
canvas_draw_str_aligned(
canvas, 64, 28, AlignCenter, AlignTop, "Point the flipper at the tag");
/* Tiny three-dot loading indicator: rotates one bright dot through
* the dot triplet so even when the tick is delayed by IR bursts the
* change is obvious and cheap to draw. */
const int dot_y = 44;
const int dot_cx = 64;
const int dot_spacing = 6;
const uint8_t phase = (t / 4U) % 3U;
for(int i = 0; i < 3; i++) {
int x = dot_cx + (i - 1) * dot_spacing;
if((uint8_t)i == phase) {
canvas_draw_disc(canvas, x, dot_y, 2);
} else {
canvas_draw_circle(canvas, x, dot_y, 1);
}
}
} else {
canvas_draw_str_aligned(canvas, 64, 30, AlignCenter, AlignTop, "Flipped ;)");
}
/* Bottom action hint. */
canvas_set_font(canvas, FontSecondary);
if(app->tx_spam) {
canvas_draw_str_aligned(canvas, 64, 55, AlignCenter, AlignTop, "[<-] Stop Repeat");
} else {
canvas_draw_str_aligned(
canvas, 64, 55, AlignCenter, AlignTop, app->tx_active ? "[<-] Cancel" : "[<-] Back");
}
}
void tagtinker_scene_transmit_on_enter(void* context) {
TagTinkerApp* app = context;
if(!app->transmit_view_allocated) {
view_allocate_model(app->transmit_view, ViewModelTypeLockFree, sizeof(TxViewModel));
view_set_context(app->transmit_view, app);
view_set_draw_callback(app->transmit_view, transmit_draw_cb);
app->transmit_view_allocated = true;
}
TxViewModel* model = view_get_model(app->transmit_view);
model->app = app;
model->tick = 0;
model->completed = false;
model->ok = true;
view_commit_model(app->transmit_view, true);
view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewTransmit);
app->tx_active = true;
furi_thread_set_callback(app->tx_thread, tx_thread_callback);
furi_thread_start(app->tx_thread);
}
bool tagtinker_scene_transmit_on_event(void* context, SceneManagerEvent event) {
TagTinkerApp* app = context;
if(event.type == SceneManagerEventTypeBack) {
if(app->tx_active) {
app->tx_active = false;
tagtinker_ir_stop();
return true;
} else {
if(!scene_manager_search_and_switch_to_previous_scene(app->scene_manager, TagTinkerSceneTargetActions)) {
if(!scene_manager_search_and_switch_to_previous_scene(app->scene_manager, TagTinkerSceneAbout)) {
if(!scene_manager_search_and_switch_to_previous_scene(app->scene_manager, TagTinkerSceneBroadcast)) {
if(!scene_manager_search_and_switch_to_previous_scene(app->scene_manager, TagTinkerSceneBroadcastMenu)) {
scene_manager_search_and_switch_to_previous_scene(app->scene_manager, TagTinkerSceneMainMenu);
}
}
}
}
return true;
}
} else if(event.type == SceneManagerEventTypeTick) {
TxViewModel* model = view_get_model(app->transmit_view);
model->tick++;
view_commit_model(app->transmit_view, true);
return true;
} else if(event.type == SceneManagerEventTypeCustom) {
if(event.event == 101 || event.event == 102) { /* Thread Done */
app->tx_active = false;
tagtinker_ir_deinit();
TxViewModel* model = view_get_model(app->transmit_view);
model->completed = true;
model->ok = (event.event == 101);
view_commit_model(app->transmit_view, true);
notification_message(app->notifications, &sequence_success);
}
return true;
}
return false;
}
void tagtinker_scene_transmit_on_exit(void* context) {
TagTinkerApp* app = context;
app->tx_active = false;
tagtinker_ir_stop();
furi_thread_join(app->tx_thread);
tagtinker_ir_deinit();
tagtinker_free_frame_sequence(app);
memset(&app->image_tx_job, 0, sizeof(app->image_tx_job));
}
@@ -0,0 +1,164 @@
/*
* Startup warning scene
*/
#include "../tagtinker_app.h"
#include <gui/elements.h>
enum {
TagTinkerStartupWarningContinue,
};
typedef struct {
uint8_t page;
} WarningViewModel;
typedef struct {
const char* title;
const char* lines[2];
} WarningPage;
static const WarningPage startup_warning_pages[] = {
{
.title = "RESEARCH TOOL:",
.lines =
{
"Educational tool for",
"infrared ESL study.",
},
},
{
.title = "PERMISSION:",
.lines =
{
"Use only on tags",
"you own or may test.",
},
},
{
.title = "CAUTION:",
.lines =
{
"Unauthorized use",
"may be illegal.",
},
},
{
.title = "RESPONSIBILITY:",
.lines =
{
"You are responsible",
"for your actions.",
},
},
};
static const uint8_t startup_warning_page_count =
sizeof(startup_warning_pages) / sizeof(startup_warning_pages[0]);
static bool warning_is_unlocked(const WarningViewModel* model) {
return model->page >= (startup_warning_page_count - 1);
}
static void warning_draw_cb(Canvas* canvas, void* _model) {
WarningViewModel* model = _model;
bool unlocked = warning_is_unlocked(model);
const WarningPage* page = &startup_warning_pages[model->page];
canvas_clear(canvas);
canvas_set_font(canvas, FontPrimary);
canvas_draw_str_aligned(canvas, 64, 13, AlignCenter, AlignCenter, page->title);
canvas_set_font(canvas, FontSecondary);
canvas_draw_str_aligned(canvas, 64, 29, AlignCenter, AlignCenter, page->lines[0]);
canvas_draw_str_aligned(canvas, 64, 39, AlignCenter, AlignCenter, page->lines[1]);
if(model->page > 0) {
elements_button_left(canvas, "Prev");
}
elements_button_right(canvas, unlocked ? "OK" : "Next");
}
static bool warning_input_cb(InputEvent* event, void* context) {
TagTinkerApp* app = context;
bool handled = false;
if(event->type != InputTypeShort && event->type != InputTypeRepeat) {
return false;
}
WarningViewModel* model = view_get_model(app->warning_view);
switch(event->key) {
case InputKeyDown:
case InputKeyRight:
if(model->page + 1 < startup_warning_page_count) {
model->page++;
handled = true;
}
break;
case InputKeyUp:
case InputKeyLeft:
if(model->page > 0) {
model->page--;
handled = true;
}
break;
case InputKeyOk:
if(event->type == InputTypeShort) {
if(warning_is_unlocked(model)) {
handled = true;
view_commit_model(app->warning_view, false);
view_dispatcher_send_custom_event(
app->view_dispatcher, TagTinkerStartupWarningContinue);
return true;
} else if(model->page + 1 < startup_warning_page_count) {
model->page++;
handled = true;
}
}
break;
default:
break;
}
view_commit_model(app->warning_view, handled);
return handled;
}
void tagtinker_scene_warning_on_enter(void* ctx) {
TagTinkerApp* app = ctx;
if(!app->warning_view_allocated) {
view_allocate_model(app->warning_view, ViewModelTypeLockFree, sizeof(WarningViewModel));
view_set_context(app->warning_view, app);
view_set_draw_callback(app->warning_view, warning_draw_cb);
view_set_input_callback(app->warning_view, warning_input_cb);
app->warning_view_allocated = true;
}
WarningViewModel* model = view_get_model(app->warning_view);
model->page = 0;
view_commit_model(app->warning_view, true);
view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewWarning);
}
bool tagtinker_scene_warning_on_event(void* ctx, SceneManagerEvent event) {
TagTinkerApp* app = ctx;
if(event.type == SceneManagerEventTypeCustom &&
event.event == TagTinkerStartupWarningContinue) {
scene_manager_search_and_switch_to_another_scene(
app->scene_manager, TagTinkerSceneMainMenu);
return true;
}
return false;
}
void tagtinker_scene_warning_on_exit(void* ctx) {
UNUSED(ctx);
}
@@ -0,0 +1,195 @@
/*
* WiFi Plugins
* ============
*
* Top-level scene that:
* 1. Lazy-allocates the TagTinkerWifi link and opens the UART.
* 2. Asks the ESP for its plugin list (LIST_PLUGINS).
* 3. Renders a submenu of plugins, plus persistent header rows for
* "WiFi Setup" and "Forget WiFi" so the user can manage credentials
* without leaving the page.
* 4. Routes to the Run scene when a plugin is picked.
*
* Events from TagTinkerWifi land on the FAP main thread via
* view_dispatcher custom events (we marshal via the message queue rather
* than touching submenu* directly from the worker thread, which is not
* thread-safe).
*/
#include "../tagtinker_app.h"
#include "../wifi/tagtinker_wifi.h"
#include <string.h>
#include <stdio.h>
#define EVT_PLUGIN_BASE 0x100u
#define EVT_WIFI_SETUP 0x001u
#define EVT_WIFI_FORGET 0x002u
#define EVT_WIFI_REFRESH 0x003u
#define EVT_LINK_LIST_DONE 0x200u
#define EVT_LINK_STATUS 0x201u
#define EVT_LINK_LOST 0x202u
#define EVT_LINK_HELLO 0x203u
static TagTinkerWifiPlugin* plugin_array(TagTinkerApp* app) {
return (TagTinkerWifiPlugin*)app->wifi_plugins;
}
static void wifi_plugins_event_cb(const TtWifiEvent* e, void* user) {
TagTinkerApp* app = user;
switch(e->type) {
case TtWifiEvtHello:
view_dispatcher_send_custom_event(app->view_dispatcher, EVT_LINK_HELLO);
break;
case TtWifiEvtWifiStatus:
app->wifi_link_state = (uint8_t)e->u0;
app->wifi_rssi = (int8_t)e->i1;
strncpy(app->wifi_ssid, e->str0 ? e->str0 : "", sizeof(app->wifi_ssid) - 1);
strncpy(app->wifi_ip, e->str1 ? e->str1 : "", sizeof(app->wifi_ip) - 1);
view_dispatcher_send_custom_event(app->view_dispatcher, EVT_LINK_STATUS);
break;
case TtWifiEvtPlugin:
if(app->wifi_plugin_count < TT_WIFI_MAX_FAP_PLUGINS && e->plugin) {
plugin_array(app)[app->wifi_plugin_count++] = *e->plugin;
}
break;
case TtWifiEvtPluginsEnd:
view_dispatcher_send_custom_event(app->view_dispatcher, EVT_LINK_LIST_DONE);
break;
case TtWifiEvtLinkLost:
view_dispatcher_send_custom_event(app->view_dispatcher, EVT_LINK_LOST);
break;
default: break; /* progress/result/error are handled by the run scene */
}
}
static void wifi_plugins_submenu_cb(void* ctx, uint32_t index) {
TagTinkerApp* app = ctx;
view_dispatcher_send_custom_event(app->view_dispatcher, index);
}
/* Update only the header (status badge) without rebuilding the submenu,
* so the periodic 2s WIFI_STATUS push doesn't kick the cursor back to
* the top entry every time. */
static void refresh_header(TagTinkerApp* app) {
char hdr[40];
const char* badge = "...";
switch(app->wifi_link_state) {
case TT_WIFI_DISCONNECTED: badge = "off"; break;
case TT_WIFI_CONNECTING: badge = "..."; break;
case TT_WIFI_CONNECTED: badge = "OK"; break;
case TT_WIFI_AUTH_FAILED: badge = "auth!"; break;
case TT_WIFI_NO_AP: badge = "no AP"; break;
}
snprintf(hdr, sizeof(hdr), "WiFi Plugins [%s]", badge);
submenu_set_header(app->submenu, hdr);
}
static void rebuild_submenu(TagTinkerApp* app) {
/* Preserve the current cursor position across rebuilds. */
uint32_t saved = submenu_get_selected_item(app->submenu);
submenu_reset(app->submenu);
refresh_header(app);
if(app->wifi_plugin_count == 0) {
const char* placeholder = app->wifi_plugins_loading
? "Loading plugins..."
: "(no plugins yet)";
submenu_add_item(app->submenu, placeholder, EVT_WIFI_REFRESH,
wifi_plugins_submenu_cb, app);
} else {
for(uint8_t i = 0; i < app->wifi_plugin_count; i++) {
const TagTinkerWifiPlugin* p = &plugin_array(app)[i];
submenu_add_item(app->submenu, p->name, EVT_PLUGIN_BASE + i,
wifi_plugins_submenu_cb, app);
}
}
submenu_add_item(app->submenu, "WiFi Setup", EVT_WIFI_SETUP,
wifi_plugins_submenu_cb, app);
submenu_add_item(app->submenu, "Forget WiFi", EVT_WIFI_FORGET,
wifi_plugins_submenu_cb, app);
submenu_add_item(app->submenu, "Refresh Plugins", EVT_WIFI_REFRESH,
wifi_plugins_submenu_cb, app);
submenu_set_selected_item(app->submenu, saved);
}
void tagtinker_scene_wifi_plugins_on_enter(void* ctx) {
TagTinkerApp* app = ctx;
/* Lazy-allocate the link + plugin cache the first time we enter.
* The cache is the dominant heap cost of the WiFi flow (~1.9 KB per
* slot), so capping at TT_WIFI_MAX_FAP_PLUGINS keeps the IR TX
* pipeline that follows a plugin run well-fed on heap. */
if(!app->wifi) {
const size_t bytes = sizeof(TagTinkerWifiPlugin) * TT_WIFI_MAX_FAP_PLUGINS;
app->wifi_plugins = malloc(bytes);
memset(app->wifi_plugins, 0, bytes);
app->wifi = tagtinker_wifi_alloc(wifi_plugins_event_cb, app);
}
if(!tagtinker_wifi_open((TagTinkerWifi*)app->wifi)) {
/* UART couldn't be acquired - rare unless another app holds it. */
app->wifi_link_state = TT_WIFI_DISCONNECTED;
}
/* Always re-query plugins on entry; the ESP may have been re-flashed. */
app->wifi_plugin_count = 0;
app->wifi_plugins_loading = true;
rebuild_submenu(app);
view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewSubmenu);
tagtinker_wifi_query_status((TagTinkerWifi*)app->wifi);
tagtinker_wifi_list_plugins((TagTinkerWifi*)app->wifi);
}
bool tagtinker_scene_wifi_plugins_on_event(void* ctx, SceneManagerEvent event) {
TagTinkerApp* app = ctx;
if(event.type != SceneManagerEventTypeCustom) return false;
if(event.event == EVT_WIFI_SETUP) {
scene_manager_next_scene(app->scene_manager, TagTinkerSceneWifiSetup);
return true;
}
if(event.event == EVT_WIFI_FORGET) {
tagtinker_wifi_forget((TagTinkerWifi*)app->wifi);
app->wifi_link_state = TT_WIFI_DISCONNECTED;
rebuild_submenu(app);
return true;
}
if(event.event == EVT_WIFI_REFRESH) {
app->wifi_plugin_count = 0;
app->wifi_plugins_loading = true;
rebuild_submenu(app);
tagtinker_wifi_list_plugins((TagTinkerWifi*)app->wifi);
return true;
}
if(event.event == EVT_LINK_STATUS) {
/* Lightweight: only refresh the status badge, keep the cursor put. */
refresh_header(app);
return true;
}
if(event.event == EVT_LINK_LIST_DONE || event.event == EVT_LINK_HELLO ||
event.event == EVT_LINK_LOST) {
app->wifi_plugins_loading = false;
rebuild_submenu(app);
return true;
}
if(event.event >= EVT_PLUGIN_BASE && event.event < EVT_PLUGIN_BASE + TT_WIFI_MAX_FAP_PLUGINS) {
uint8_t idx = (uint8_t)(event.event - EVT_PLUGIN_BASE);
if(idx < app->wifi_plugin_count) {
app->wifi_selected_plugin = (int8_t)idx;
scene_manager_next_scene(app->scene_manager, TagTinkerSceneWifiRun);
return true;
}
}
return false;
}
void tagtinker_scene_wifi_plugins_on_exit(void* ctx) {
TagTinkerApp* app = ctx;
submenu_reset(app->submenu);
/* Keep the UART open while we stay inside the WiFi flow. The link is
* closed in the app's free path or when leaving the WiFi area entirely
* (the run scene calls back into us, so don't close on every exit). */
}
@@ -0,0 +1,416 @@
/*
* WiFi Run
* ========
*
* 1. Renders a VariableItemList of the selected plugin's parameters.
* - Enum : cycle through options.
* - Bool : Off/On toggle.
* - Int : numeric range.
* - String: tap to open text_input, write back into wifi_param_values.
* 2. Adds a "Generate" item at the bottom that:
* - Picks a target (defaults to the currently-selected target;
* if none, asks the user via the popup).
* - Sends RUN_PLUGIN to the ESP.
* - Switches to a Popup view that streams progress updates.
* - On RESULT_END, writes a BMP and chains to the existing transmit
* scene via tagtinker_prepare_bmp_tx().
* - On ERROR, shows the message in the popup.
*/
#include "../tagtinker_app.h"
#include "../wifi/tagtinker_wifi.h"
#include "../wifi/tagtinker_wifi_bmp.h"
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#define EVT_GENERATE 0xD0u
#define EVT_PARAM_STRING 0xD1u
#define EVT_TEXT_DONE 0xD2u
#define EVT_PROGRESS 0xD3u
#define EVT_ERROR 0xD4u
#define EVT_RESULT_DONE 0xD5u
/* Per-scene state held in the app to avoid statics. */
static TagTinkerWifiBmpWriter s_bmp_writer;
static int8_t s_string_param_being_edited = -1;
static TagTinkerWifiPlugin* current_plugin(TagTinkerApp* app) {
if(app->wifi_selected_plugin < 0) return NULL;
TagTinkerWifiPlugin* arr = (TagTinkerWifiPlugin*)app->wifi_plugins;
return &arr[app->wifi_selected_plugin];
}
/* ---- Variable-item callbacks --------------------------------------------*/
/* Because VariableItem doesn't directly expose row index in its callback,
* we encode the param index in the high byte of the variable item's
* `current_value_index` when a callback fires - we re-pack it elsewhere.
* Simpler: we maintain a parallel array of the items we created, in order,
* and use variable_item_set_current_value_text to update the displayed text.
* The scenes module provides no easier way; this approach is minimal. */
static VariableItem* s_param_items[6];
static void item_changed_enum(VariableItem* item) {
TagTinkerApp* app = variable_item_get_context(item);
TagTinkerWifiPlugin* p = current_plugin(app);
if(!p) return;
/* Locate the param index for this item by matching pointer in s_param_items. */
for(uint8_t i = 0; i < p->param_count; i++) {
if(s_param_items[i] != item) continue;
const TtWifiParam* sp = &p->params[i];
uint8_t idx = variable_item_get_current_value_index(item);
if(idx >= sp->option_count) idx = 0;
const char* opt = sp->options[idx];
variable_item_set_current_value_text(item, opt);
strncpy(app->wifi_param_values[i], opt, sizeof(app->wifi_param_values[i]) - 1);
app->wifi_param_values[i][sizeof(app->wifi_param_values[i]) - 1] = 0;
break;
}
}
static void item_changed_bool(VariableItem* item) {
TagTinkerApp* app = variable_item_get_context(item);
TagTinkerWifiPlugin* p = current_plugin(app);
if(!p) return;
for(uint8_t i = 0; i < p->param_count; i++) {
if(s_param_items[i] != item) continue;
uint8_t idx = variable_item_get_current_value_index(item);
variable_item_set_current_value_text(item, idx ? "On" : "Off");
strcpy(app->wifi_param_values[i], idx ? "1" : "0");
break;
}
}
static void item_changed_int(VariableItem* item) {
TagTinkerApp* app = variable_item_get_context(item);
TagTinkerWifiPlugin* p = current_plugin(app);
if(!p) return;
for(uint8_t i = 0; i < p->param_count; i++) {
if(s_param_items[i] != item) continue;
const TtWifiParam* sp = &p->params[i];
int32_t v = sp->int_min + variable_item_get_current_value_index(item);
char buf[16]; snprintf(buf, sizeof(buf), "%ld", (long)v);
variable_item_set_current_value_text(item, buf);
strncpy(app->wifi_param_values[i], buf, sizeof(app->wifi_param_values[i]) - 1);
break;
}
}
static void item_enter_cb(void* ctx, uint32_t index) {
TagTinkerApp* app = ctx;
TagTinkerWifiPlugin* p = current_plugin(app);
if(!p) return;
/* The very last item is "Generate"; any string-param item opens the
* text_input view. */
if(index < p->param_count) {
const TtWifiParam* sp = &p->params[index];
if(sp->type != TT_PARAM_STRING) return;
s_string_param_being_edited = (int8_t)index;
view_dispatcher_send_custom_event(app->view_dispatcher, EVT_PARAM_STRING);
} else {
view_dispatcher_send_custom_event(app->view_dispatcher, EVT_GENERATE);
}
}
/* ---- Build the param list ---------------------------------------------- */
static void seed_param_value(TagTinkerApp* app, const TtWifiParam* sp, uint8_t i) {
/* If we already have a value (e.g. text_input edit), keep it. The
* scene's on_enter wipes the slots fresh per plugin to prevent the
* shared array leaking values across plugin selections. */
if(app->wifi_param_values[i][0] != 0) return;
strncpy(app->wifi_param_values[i], sp->default_value,
sizeof(app->wifi_param_values[i]) - 1);
app->wifi_param_values[i][sizeof(app->wifi_param_values[i]) - 1] = 0;
}
static void build_param_list(TagTinkerApp* app) {
VariableItemList* list = app->var_item_list;
variable_item_list_reset(list);
memset(s_param_items, 0, sizeof(s_param_items));
TagTinkerWifiPlugin* p = current_plugin(app);
if(!p) return;
for(uint8_t i = 0; i < p->param_count; i++) {
const TtWifiParam* sp = &p->params[i];
seed_param_value(app, sp, i);
VariableItem* it = NULL;
if(sp->type == TT_PARAM_ENUM) {
it = variable_item_list_add(list, sp->label, sp->option_count,
item_changed_enum, app);
/* Default-select the option matching the seeded value. */
uint8_t sel = 0;
for(uint8_t j = 0; j < sp->option_count; j++) {
if(strcmp(app->wifi_param_values[i], sp->options[j]) == 0) {
sel = j; break;
}
}
variable_item_set_current_value_index(it, sel);
variable_item_set_current_value_text(it, sp->options[sel]);
} else if(sp->type == TT_PARAM_BOOL) {
it = variable_item_list_add(list, sp->label, 2, item_changed_bool, app);
uint8_t sel = (app->wifi_param_values[i][0] == '1') ? 1 : 0;
variable_item_set_current_value_index(it, sel);
variable_item_set_current_value_text(it, sel ? "On" : "Off");
} else if(sp->type == TT_PARAM_INT) {
int32_t range = sp->int_max - sp->int_min + 1;
if(range <= 0 || range > 100) range = 1;
it = variable_item_list_add(list, sp->label, (uint8_t)range,
item_changed_int, app);
int32_t cur = atoi(app->wifi_param_values[i]);
if(cur < sp->int_min) cur = sp->int_min;
uint8_t idx = (uint8_t)(cur - sp->int_min);
variable_item_set_current_value_index(it, idx);
char buf[16]; snprintf(buf, sizeof(buf), "%ld", (long)cur);
variable_item_set_current_value_text(it, buf);
} else {
/* String: clickable, opens text_input. */
it = variable_item_list_add(list, sp->label, 1, NULL, app);
variable_item_set_current_value_text(it,
app->wifi_param_values[i][0] ? app->wifi_param_values[i] : "(set)");
}
s_param_items[i] = it;
}
variable_item_list_add(list, ">> Generate <<", 0, NULL, app);
variable_item_list_set_enter_callback(list, item_enter_cb, app);
}
/* ---- Text input for STRING params -------------------------------------- */
static char s_text_buf[64];
static void text_done_cb(void* ctx) {
TagTinkerApp* app = ctx;
view_dispatcher_send_custom_event(app->view_dispatcher, EVT_TEXT_DONE);
}
static void open_text_input_for_param(TagTinkerApp* app, uint8_t i) {
text_input_reset(app->text_input);
TagTinkerWifiPlugin* p = current_plugin(app);
text_input_set_header_text(app->text_input, p->params[i].label);
strncpy(s_text_buf, app->wifi_param_values[i], sizeof(s_text_buf) - 1);
s_text_buf[sizeof(s_text_buf) - 1] = 0;
text_input_set_result_callback(
app->text_input, text_done_cb, app, s_text_buf, sizeof(s_text_buf), false);
view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewTextInput);
}
/* ---- Run + result handling --------------------------------------------- */
/* The wifi_plugins scene installed its own callback; we hot-swap it on
* scene enter and restore on exit. */
static TtWifiEventCb s_prev_cb;
static void* s_prev_user;
static void run_event_cb(const TtWifiEvent* e, void* user) {
TagTinkerApp* app = user;
switch(e->type) {
case TtWifiEvtProgress:
app->wifi_progress_pct = (uint8_t)e->u0;
strncpy(app->wifi_progress_msg, e->str0 ? e->str0 : "",
sizeof(app->wifi_progress_msg) - 1);
view_dispatcher_send_custom_event(app->view_dispatcher, EVT_PROGRESS);
break;
case TtWifiEvtResultBegin: {
uint16_t w = (uint16_t)(e->u0 & 0xFFFFu);
uint16_t h = (uint16_t)(e->u0 >> 16);
uint8_t pl = (uint8_t)(e->u1 ? e->u1 : 1);
/* Pick a palette accent that matches the destination tag's colour
* so the BMP file embeds the right BGR for previewers. The IR TX
* path itself only cares about plane bits + the target profile. */
uint8_t ar = 0xE0, ag = 0x10, ab = 0x10; /* default red */
if(app->selected_target >= 0 && app->selected_target < app->target_count) {
const TagTinkerTarget* t = &app->targets[app->selected_target];
if(t->profile.color == TagTinkerTagColorYellow) {
ar = 0xF0; ag = 0xC0; ab = 0x10;
}
}
if(!tagtinker_wifi_bmp_open(&s_bmp_writer, w, h, pl, ar, ag, ab)) {
strncpy(app->wifi_last_error, "BMP open failed",
sizeof(app->wifi_last_error) - 1);
view_dispatcher_send_custom_event(app->view_dispatcher, EVT_ERROR);
}
break;
}
case TtWifiEvtResultChunk:
tagtinker_wifi_bmp_chunk(&s_bmp_writer, e->data, e->data_len);
break;
case TtWifiEvtResultEnd:
if(!tagtinker_wifi_bmp_close(&s_bmp_writer)) {
strncpy(app->wifi_last_error, "BMP write failed",
sizeof(app->wifi_last_error) - 1);
view_dispatcher_send_custom_event(app->view_dispatcher, EVT_ERROR);
} else {
view_dispatcher_send_custom_event(app->view_dispatcher, EVT_RESULT_DONE);
}
break;
case TtWifiEvtError:
strncpy(app->wifi_last_error, e->str0 ? e->str0 : "Unknown error",
sizeof(app->wifi_last_error) - 1);
view_dispatcher_send_custom_event(app->view_dispatcher, EVT_ERROR);
break;
case TtWifiEvtLinkLost:
strncpy(app->wifi_last_error, "Dev board went silent",
sizeof(app->wifi_last_error) - 1);
view_dispatcher_send_custom_event(app->view_dispatcher, EVT_ERROR);
break;
default:
/* Hello/status/plugin events still useful: forward to the previous
* callback so the plugin-list scene can refresh on return. */
if(s_prev_cb) s_prev_cb(e, s_prev_user);
break;
}
}
static void show_progress_popup(TagTinkerApp* app) {
popup_reset(app->popup);
popup_set_header(app->popup, "WiFi Plugin", 64, 6, AlignCenter, AlignTop);
char body[120];
snprintf(body, sizeof(body), "%u%%\n%s", app->wifi_progress_pct,
app->wifi_progress_msg);
popup_set_text(app->popup, body, 64, 32, AlignCenter, AlignCenter);
view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewPopup);
}
static void show_error_popup(TagTinkerApp* app) {
popup_reset(app->popup);
popup_set_header(app->popup, "Error", 64, 6, AlignCenter, AlignTop);
popup_set_text(app->popup, app->wifi_last_error, 64, 32, AlignCenter, AlignCenter);
view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewPopup);
}
static void start_run(TagTinkerApp* app) {
TagTinkerWifiPlugin* p = current_plugin(app);
if(!p) return;
/* Need a target to pick the canvas size. Default to selected_target;
* else fallback to a reasonable sane size. */
uint16_t tw = app->esl_width ? app->esl_width : 296;
uint16_t th = app->esl_height ? app->esl_height : 128;
/* Honour the tag's accent capability: red/yellow profiles get the
* accent plane, mono profiles stay mono. The BMP writer + the IR TX
* pipeline already understand 2-plane BMPs (same convention as the
* web image prep tool), so plugins can use the accent freely. */
uint8_t accent = TT_ACCENT_NONE;
if(app->selected_target >= 0 && app->selected_target < app->target_count) {
const TagTinkerTarget* t = &app->targets[app->selected_target];
if(tagtinker_target_supports_accent(t)) {
accent = (t->profile.color == TagTinkerTagColorYellow)
? TT_ACCENT_YELLOW
: TT_ACCENT_RED;
}
}
TtWifiKV kv[6];
uint8_t n = 0;
for(uint8_t i = 0; i < p->param_count && i < 6; i++) {
kv[n].key = p->params[i].key;
kv[n].value = app->wifi_param_values[i];
n++;
}
app->wifi_progress_pct = 0;
snprintf(app->wifi_progress_msg, sizeof(app->wifi_progress_msg), "Starting...");
app->wifi_last_error[0] = 0;
app->wifi_run_in_flight = true;
show_progress_popup(app);
tagtinker_wifi_run_plugin((TagTinkerWifi*)app->wifi,
p->index, tw, th, accent, kv, n);
}
/* ---- Scene entry / event ---------------------------------------------- */
void tagtinker_scene_wifi_run_on_enter(void* ctx) {
TagTinkerApp* app = ctx;
s_string_param_being_edited = -1;
/* Reset the shared param-value array so the new plugin starts fresh
* with its own defaults (otherwise e.g. Crypto's "BTC" leaks into
* Weather's "Location" slot). */
memset(app->wifi_param_values, 0, sizeof(app->wifi_param_values));
/* Hot-swap the WiFi callback so progress/result frames land here.
* The previous callback (the plugins scene's) is restored on exit. */
if(app->wifi) {
tagtinker_wifi_set_callback(
(TagTinkerWifi*)app->wifi, run_event_cb, app,
&s_prev_cb, &s_prev_user);
}
build_param_list(app);
view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewVarItemList);
}
bool tagtinker_scene_wifi_run_on_event(void* ctx, SceneManagerEvent event) {
TagTinkerApp* app = ctx;
if(event.type != SceneManagerEventTypeCustom) return false;
switch(event.event) {
case EVT_PARAM_STRING:
if(s_string_param_being_edited >= 0)
open_text_input_for_param(app, (uint8_t)s_string_param_being_edited);
return true;
case EVT_TEXT_DONE: {
if(s_string_param_being_edited >= 0) {
uint8_t i = (uint8_t)s_string_param_being_edited;
strncpy(app->wifi_param_values[i], s_text_buf,
sizeof(app->wifi_param_values[i]) - 1);
app->wifi_param_values[i][sizeof(app->wifi_param_values[i]) - 1] = 0;
}
s_string_param_being_edited = -1;
build_param_list(app);
view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewVarItemList);
return true;
}
case EVT_GENERATE:
start_run(app);
return true;
case EVT_PROGRESS:
if(app->wifi_run_in_flight) show_progress_popup(app);
return true;
case EVT_ERROR:
app->wifi_run_in_flight = false;
tagtinker_wifi_bmp_abort(&s_bmp_writer);
show_error_popup(app);
return true;
case EVT_RESULT_DONE: {
app->wifi_run_in_flight = false;
/* Hand the BMP to the existing TX path. */
if(app->selected_target < 0 || app->selected_target >= app->target_count) {
strncpy(app->wifi_last_error,
"No saved tags - scan one in Targeted Payloads first",
sizeof(app->wifi_last_error) - 1);
show_error_popup(app);
return true;
}
const TagTinkerTarget* t = &app->targets[app->selected_target];
tagtinker_prepare_bmp_tx(app, t->plid, TAGTINKER_WIFI_TMP_BMP,
app->esl_width, app->esl_height, app->img_page);
scene_manager_next_scene(app->scene_manager, TagTinkerSceneTransmit);
return true;
}
}
return false;
}
void tagtinker_scene_wifi_run_on_exit(void* ctx) {
TagTinkerApp* app = ctx;
/* Restore the plugins-scene callback. */
if(app->wifi && s_prev_cb) {
tagtinker_wifi_set_callback(
(TagTinkerWifi*)app->wifi, s_prev_cb, s_prev_user, NULL, NULL);
s_prev_cb = NULL; s_prev_user = NULL;
}
/* Release the ~10 KB pixel buffer if a transfer was abandoned mid-flight
* (e.g. the user backs out of the popup before RESULT_END). Without this
* the buffer leaks on every run and the IR transmit scene that follows
* has noticeably less heap to malloc its plane buffers - the OOM crashes
* we were seeing. abort() is a no-op if the writer is already closed. */
tagtinker_wifi_bmp_abort(&s_bmp_writer);
variable_item_list_reset(app->var_item_list);
popup_reset(app->popup);
text_input_reset(app->text_input);
}
@@ -0,0 +1,79 @@
/*
* WiFi Setup
* ==========
*
* Two-step text input: SSID first, then password. State machine lives in
* the scene_state field of the scene manager so we can re-enter cleanly
* after the text_input view returns.
*
* state=0 -> prompt SSID
* state=1 -> prompt password
* state=2 -> sent, return to plugins scene
*/
#include "../tagtinker_app.h"
#include "../wifi/tagtinker_wifi.h"
#include <string.h>
#define EVT_TEXT_DONE 0xC1u
static void text_done_cb(void* ctx) {
TagTinkerApp* app = ctx;
view_dispatcher_send_custom_event(app->view_dispatcher, EVT_TEXT_DONE);
}
static void prompt_ssid(TagTinkerApp* app) {
text_input_reset(app->text_input);
text_input_set_header_text(app->text_input, "WiFi SSID");
/* Reuse cached creds so re-entering doesn't blank the field. */
strncpy(app->wifi_creds_ssid, app->wifi_ssid, sizeof(app->wifi_creds_ssid) - 1);
app->wifi_creds_ssid[sizeof(app->wifi_creds_ssid) - 1] = 0;
text_input_set_result_callback(
app->text_input, text_done_cb, app,
app->wifi_creds_ssid, sizeof(app->wifi_creds_ssid), false);
view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewTextInput);
}
static void prompt_password(TagTinkerApp* app) {
text_input_reset(app->text_input);
text_input_set_header_text(app->text_input, "Password");
/* Don't pre-fill the password field for visual privacy. */
app->wifi_creds_pwd[0] = 0;
text_input_set_result_callback(
app->text_input, text_done_cb, app,
app->wifi_creds_pwd, sizeof(app->wifi_creds_pwd), false);
view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewTextInput);
}
void tagtinker_scene_wifi_setup_on_enter(void* ctx) {
TagTinkerApp* app = ctx;
scene_manager_set_scene_state(app->scene_manager, TagTinkerSceneWifiSetup, 0);
prompt_ssid(app);
}
bool tagtinker_scene_wifi_setup_on_event(void* ctx, SceneManagerEvent event) {
TagTinkerApp* app = ctx;
if(event.type != SceneManagerEventTypeCustom) return false;
if(event.event != EVT_TEXT_DONE) return false;
uint32_t st = scene_manager_get_scene_state(app->scene_manager, TagTinkerSceneWifiSetup);
if(st == 0) {
scene_manager_set_scene_state(app->scene_manager, TagTinkerSceneWifiSetup, 1);
prompt_password(app);
return true;
}
/* Both fields collected - send to ESP and pop back. */
if(app->wifi) {
tagtinker_wifi_set_creds((TagTinkerWifi*)app->wifi,
app->wifi_creds_ssid, app->wifi_creds_pwd);
}
/* Wipe the password from app memory once it's on the wire. */
memset(app->wifi_creds_pwd, 0, sizeof(app->wifi_creds_pwd));
scene_manager_previous_scene(app->scene_manager);
return true;
}
void tagtinker_scene_wifi_setup_on_exit(void* ctx) {
TagTinkerApp* app = ctx;
text_input_reset(app->text_input);
}
@@ -0,0 +1,16 @@
/*
* FAP-side stub that pulls in the shared wire-protocol header. The ESP-IDF
* project keeps the canonical copy under esp32-wifi-fw/shared/; this file
* just re-exports it so FAP sources can `#include "../shared/tt_wifi_proto_fap.h"`
* without escaping the FAP project root.
*
* If you ever need to edit the protocol, edit
* esp32-wifi-fw/shared/tt_wifi_proto.h
* and run the sync script (or copy by hand) to update the body of this file.
*/
#ifndef TT_WIFI_PROTO_FAP_H
#define TT_WIFI_PROTO_FAP_H
#include "../esp32-wifi-fw/shared/tt_wifi_proto.h"
#endif
Binary file not shown.

After

Width:  |  Height:  |  Size: 95 B

@@ -0,0 +1,940 @@
/*
* TagTinker - ESL Flipper Zero application
*
* Transmit infrared commands to supported ESL displays
* using the built-in IR LED at 1.255 MHz carrier.
*
* App by I12BP8 - github.com/i12bp8
* Research by furrtek - github.com/furrtek
*
* SPDX-License-Identifier: GPL-3.0-only
*/
#include "tagtinker_app.h"
#define TAGTINKER_WEB_JOB_PATH APP_DATA_PATH("web_job.txt")
static bool navigation_cb(void* ctx) {
TagTinkerApp* app = ctx;
return scene_manager_handle_back_event(app->scene_manager);
}
static void tick_cb(void* ctx) {
TagTinkerApp* app = ctx;
scene_manager_handle_tick_event(app->scene_manager);
}
static bool custom_event_cb(void* ctx, uint32_t event) {
TagTinkerApp* app = ctx;
return scene_manager_handle_custom_event(app->scene_manager, event);
}
extern const SceneManagerHandlers tagtinker_scene_handlers;
#define TAGTINKER_STREAM_PIXEL_BUDGET 131072U
static void tagtinker_clamp_region_to_target(
const TagTinkerApp* app,
uint16_t width,
uint16_t height,
uint16_t* pos_x,
uint16_t* pos_y) {
if(!app || !pos_x || !pos_y) return;
if(app->selected_target < 0 || app->selected_target >= app->target_count) return;
const TagTinkerTarget* target = &app->targets[app->selected_target];
if(!target->profile.known || !target->profile.width || !target->profile.height) return;
uint16_t max_x =
(width < target->profile.width) ? (uint16_t)(target->profile.width - width) : 0U;
uint16_t max_y =
(height < target->profile.height) ? (uint16_t)(target->profile.height - height) : 0U;
if(*pos_x > max_x) *pos_x = max_x;
if(*pos_y > max_y) *pos_y = max_y;
}
void tagtinker_target_refresh_profile(TagTinkerTarget* target) {
if(!target) return;
memset(&target->profile, 0, sizeof(target->profile));
tagtinker_barcode_to_profile(target->barcode, &target->profile);
}
void tagtinker_target_set_default_name(TagTinkerApp* app, TagTinkerTarget* target) {
if(!target || !app) return;
snprintf(target->name, sizeof(target->name), "tag%d", app->target_count + 1);
}
int8_t tagtinker_find_target_by_barcode(const TagTinkerApp* app, const char* barcode) {
if(!app || !barcode || !*barcode) return -1;
for(uint8_t i = 0; i < app->target_count; i++) {
if(strcmp(app->targets[i].barcode, barcode) == 0) {
return (int8_t)i;
}
}
return -1;
}
int8_t tagtinker_ensure_target(TagTinkerApp* app, const char* barcode) {
if(!app || !barcode) return -1;
int8_t existing = tagtinker_find_target_by_barcode(app, barcode);
if(existing >= 0) return existing;
if(app->target_count >= TAGTINKER_MAX_TARGETS) return -1;
TagTinkerTarget* target = &app->targets[app->target_count];
memset(target, 0, sizeof(*target));
strncpy(target->barcode, barcode, TAGTINKER_BC_LEN);
target->barcode[TAGTINKER_BC_LEN] = '\0';
if(!tagtinker_barcode_to_plid(target->barcode, target->plid)) {
memset(target, 0, sizeof(*target));
return -1;
}
tagtinker_target_set_default_name(app, target);
tagtinker_target_refresh_profile(target);
app->target_count++;
tagtinker_targets_save(app);
return (int8_t)(app->target_count - 1U);
}
bool tagtinker_find_latest_synced_image(
const TagTinkerApp* app,
const char* barcode,
TagTinkerSyncedImage* image) {
if(!app || !barcode || !image) return false;
Storage* storage = furi_record_open(RECORD_STORAGE);
File* file = storage_file_alloc(storage);
bool found = false;
if(storage_file_open(file, APP_DATA_PATH("synced_images.txt"), FSAM_READ, FSOM_OPEN_EXISTING)) {
/*
* Index file can grow well beyond 512 bytes with many synced images.
* Previously the 512-byte buffer truncated file paths mid-name
* (e.g. "D72B7A.bm" instead of "D72B7A.bmp"), causing bmp_open
* to fail and the entire IR transmission to abort in ~1ms.
*/
uint64_t file_size = storage_file_size(file);
if(file_size > 16384U) file_size = 16384U;
size_t alloc_size = (size_t)file_size + 1U;
char* buf = malloc(alloc_size);
if(!buf) {
storage_file_close(file);
storage_file_free(file);
furi_record_close(RECORD_STORAGE);
return false;
}
uint16_t read = storage_file_read(file, buf, (uint16_t)file_size);
buf[read] = '\0';
storage_file_close(file);
char* line = buf;
while(line && *line) {
char* nl = strchr(line, '\n');
if(nl) *nl = '\0';
if(*line) {
char* cursor = line;
char* current_barcode = strchr(cursor, '|');
if(current_barcode) {
*current_barcode++ = '\0';
char* width = strchr(current_barcode, '|');
if(width) {
*width++ = '\0';
char* height = strchr(width, '|');
if(height) {
*height++ = '\0';
char* page = strchr(height, '|');
if(page) {
*page++ = '\0';
char* path = strchr(page, '|');
if(path) {
*path++ = '\0';
if(strcmp(current_barcode, barcode) == 0) {
strncpy(image->barcode, current_barcode, TAGTINKER_BC_LEN);
image->width = (uint16_t)atoi(width);
image->height = (uint16_t)atoi(height);
image->page = (uint8_t)atoi(page);
strncpy(image->image_path, path, TAGTINKER_IMAGE_PATH_LEN);
found = true;
}
}
}
}
}
}
}
line = nl ? (nl + 1) : NULL;
}
free(buf);
}
storage_file_free(file);
furi_record_close(RECORD_STORAGE);
return found;
}
size_t tagtinker_delete_synced_images_for_barcode(TagTinkerApp* app, const char* barcode) {
UNUSED(app);
if(!barcode || !*barcode) return 0U;
Storage* storage = furi_record_open(RECORD_STORAGE);
File* file = storage_file_alloc(storage);
size_t removed_count = 0U;
if(storage_file_open(file, APP_DATA_PATH("synced_images.txt"), FSAM_READ, FSOM_OPEN_EXISTING)) {
uint64_t size = storage_file_size(file);
if(size > 0U && size < 16384U) {
char* input = malloc((size_t)size + 1U);
char* output = malloc((size_t)size + 1U);
if(input && output) {
uint16_t read = storage_file_read(file, input, (uint16_t)size);
input[read] = '\0';
size_t output_len = 0U;
char* line = input;
while(line && *line) {
char* nl = strchr(line, '\n');
if(nl) *nl = '\0';
if(*line) {
char line_copy[256];
strncpy(line_copy, line, sizeof(line_copy) - 1U);
line_copy[sizeof(line_copy) - 1U] = '\0';
char* cursor = line;
char* current_barcode = strchr(cursor, '|');
if(current_barcode) {
*current_barcode++ = '\0';
char* width = strchr(current_barcode, '|');
if(width) {
*width++ = '\0';
char* height = strchr(width, '|');
if(height) {
*height++ = '\0';
char* page = strchr(height, '|');
if(page) {
*page++ = '\0';
char* path = strchr(page, '|');
if(path) {
*path++ = '\0';
if(strcmp(current_barcode, barcode) == 0) {
storage_common_remove(storage, path);
removed_count++;
} else {
size_t line_len = strlen(line_copy);
if(output_len + line_len + 2U <= (size_t)size + 1U) {
strcpy(output + output_len, line_copy);
output_len += line_len;
output[output_len++] = '\n';
output[output_len] = '\0';
}
}
}
}
}
}
}
}
line = nl ? (nl + 1) : NULL;
}
storage_file_close(file);
if(removed_count > 0U) {
if(output_len == 0U) {
storage_common_remove(storage, APP_DATA_PATH("synced_images.txt"));
} else if(storage_file_open(file, APP_DATA_PATH("synced_images.txt"), FSAM_WRITE, FSOM_CREATE_ALWAYS)) {
storage_file_write(file, output, (uint16_t)output_len);
storage_file_close(file);
}
}
}
free(output);
free(input);
} else {
storage_file_close(file);
}
}
storage_file_free(file);
furi_record_close(RECORD_STORAGE);
return removed_count;
}
bool tagtinker_delete_target(TagTinkerApp* app, uint8_t index) {
if(!app || index >= app->target_count) return false;
tagtinker_delete_synced_images_for_barcode(app, app->targets[index].barcode);
if(index + 1U < app->target_count) {
memmove(
&app->targets[index],
&app->targets[index + 1U],
sizeof(TagTinkerTarget) * (size_t)(app->target_count - index - 1U));
}
memset(&app->targets[app->target_count - 1U], 0, sizeof(TagTinkerTarget));
app->target_count--;
app->selected_target = -1;
app->barcode[0] = '\0';
memset(app->plid, 0, sizeof(app->plid));
app->barcode_valid = false;
return tagtinker_targets_save(app);
}
bool tagtinker_target_supports_graphics(const TagTinkerTarget* target) {
if(!target) return false;
return target->profile.kind != TagTinkerTagKindSegment;
}
bool tagtinker_target_supports_accent(const TagTinkerTarget* target) {
if(!target) return false;
return target->profile.color == TagTinkerTagColorRed ||
target->profile.color == TagTinkerTagColorYellow;
}
const char* tagtinker_profile_kind_label(TagTinkerTagKind kind) {
switch(kind) {
case TagTinkerTagKindDotMatrix:
return "Graphic";
case TagTinkerTagKindSegment:
return "Segment";
default:
return "Unknown";
}
}
const char* tagtinker_profile_color_label(TagTinkerTagColor color) {
switch(color) {
case TagTinkerTagColorMono:
return "Mono";
case TagTinkerTagColorRed:
return "Red";
case TagTinkerTagColorYellow:
return "Yellow";
default:
return "Unknown";
}
}
void tagtinker_free_frame_sequence(TagTinkerApp* app) {
if(!app || !app->frame_sequence) return;
for(size_t i = 0; i < app->frame_seq_count; i++) {
free(app->frame_sequence[i]);
}
free(app->frame_sequence);
free(app->frame_lengths);
free(app->frame_repeats);
app->frame_sequence = NULL;
app->frame_lengths = NULL;
app->frame_repeats = NULL;
app->frame_seq_count = 0;
}
uint16_t tagtinker_pick_chunk_height(uint16_t width, bool color_clear) {
if(width == 0) return 1;
size_t plane_budget = color_clear ? (TAGTINKER_STREAM_PIXEL_BUDGET / 2U) : TAGTINKER_STREAM_PIXEL_BUDGET;
uint16_t chunk_h = (uint16_t)(plane_budget / width);
if(chunk_h == 0) chunk_h = 1;
return chunk_h;
}
void tagtinker_prepare_text_tx(TagTinkerApp* app, const uint8_t plid[4]) {
if(!app) return;
tagtinker_free_frame_sequence(app);
memset(&app->image_tx_job, 0, sizeof(app->image_tx_job));
app->image_tx_job.mode = TagTinkerTxModeTextImage;
memcpy(app->image_tx_job.plid, plid, sizeof(app->image_tx_job.plid));
app->image_tx_job.page = (app->img_page > 0U) ? (uint8_t)(app->img_page - 1U) : 0U;
if(app->image_tx_job.page > 7U) app->image_tx_job.page = 7U;
app->image_tx_job.width = app->esl_width;
app->image_tx_job.height = app->esl_height;
app->image_tx_job.pos_x = app->draw_x;
app->image_tx_job.pos_y = app->draw_y;
tagtinker_clamp_region_to_target(
app,
app->image_tx_job.width,
app->image_tx_job.height,
&app->image_tx_job.pos_x,
&app->image_tx_job.pos_y);
}
void tagtinker_prepare_bmp_tx(
TagTinkerApp* app,
const uint8_t plid[4],
const char* image_path,
uint16_t width,
uint16_t height,
uint8_t page) {
if(!app) return;
tagtinker_free_frame_sequence(app);
memset(&app->image_tx_job, 0, sizeof(app->image_tx_job));
app->image_tx_job.mode = TagTinkerTxModeBmpImage;
memcpy(app->image_tx_job.plid, plid, sizeof(app->image_tx_job.plid));
app->image_tx_job.page = page;
app->image_tx_job.width = width;
app->image_tx_job.height = height;
app->image_tx_job.pos_x = app->draw_x;
app->image_tx_job.pos_y = app->draw_y;
tagtinker_clamp_region_to_target(
app,
app->image_tx_job.width,
app->image_tx_job.height,
&app->image_tx_job.pos_x,
&app->image_tx_job.pos_y);
if(image_path) {
strncpy(app->image_tx_job.image_path, image_path, TAGTINKER_IMAGE_PATH_LEN);
app->image_tx_job.image_path[TAGTINKER_IMAGE_PATH_LEN] = '\0';
}
}
void tagtinker_select_target(TagTinkerApp* app, uint8_t index) {
if(!app || index >= app->target_count) return;
app->selected_target = (int8_t)index;
strncpy(app->barcode, app->targets[index].barcode, TAGTINKER_BC_LEN);
app->barcode[TAGTINKER_BC_LEN] = '\0';
memcpy(app->plid, app->targets[index].plid, sizeof(app->plid));
app->barcode_valid = true;
app->esl_width = app->targets[index].profile.width;
app->esl_height = app->targets[index].profile.height;
if(app->esl_width == 0 || app->esl_height == 0) {
app->esl_width = 200;
app->esl_height = 80;
}
}
void tagtinker_settings_load(TagTinkerApp* app) {
Storage* storage = furi_record_open(RECORD_STORAGE);
File* file = storage_file_alloc(storage);
app->show_startup_warning = true;
app->data_frame_repeats = 2;
app->signal_mode = TagTinkerSignalPP4;
if(storage_file_open(file, APP_DATA_PATH("settings.txt"), FSAM_READ, FSOM_OPEN_EXISTING)) {
char buf[32];
uint16_t read = storage_file_read(file, buf, sizeof(buf) - 1);
buf[read] = '\0';
storage_file_close(file);
int warn, rep, sig;
if(sscanf(buf, "%d|%d|%d", &warn, &rep, &sig) == 3) {
app->show_startup_warning = (warn != 0);
app->data_frame_repeats = (uint16_t)rep;
} else if(sscanf(buf, "%d|%d", &warn, &rep) == 2) {
app->show_startup_warning = (warn != 0);
app->data_frame_repeats = (uint16_t)rep;
}
}
if(app->data_frame_repeats < 1U) app->data_frame_repeats = 1U;
if(app->data_frame_repeats > 10U) app->data_frame_repeats = 10U;
app->signal_mode = TagTinkerSignalPP4;
storage_file_free(file);
furi_record_close(RECORD_STORAGE);
}
bool tagtinker_settings_save(const TagTinkerApp* app) {
Storage* storage = furi_record_open(RECORD_STORAGE);
storage_common_mkdir(storage, APP_DATA_PATH(""));
File* file = storage_file_alloc(storage);
bool ok = false;
if(storage_file_open(file, APP_DATA_PATH("settings.txt"), FSAM_WRITE, FSOM_CREATE_ALWAYS)) {
char buf[32];
int len = snprintf(
buf,
sizeof(buf),
"%d|%u|%d",
app->show_startup_warning ? 1 : 0,
app->data_frame_repeats,
(int)TagTinkerSignalPP4);
if(len > 0 && storage_file_write(file, buf, (uint16_t)len)) {
ok = true;
}
storage_file_close(file);
}
storage_file_free(file);
furi_record_close(RECORD_STORAGE);
return ok;
}
void tagtinker_targets_load(TagTinkerApp* app) {
app->target_count = 0;
Storage* storage = furi_record_open(RECORD_STORAGE);
File* file = storage_file_alloc(storage);
if(storage_file_open(file, APP_DATA_PATH("targets.txt"), FSAM_READ, FSOM_OPEN_EXISTING)) {
char buf[1024];
uint16_t read = storage_file_read(file, buf, sizeof(buf) - 1);
buf[read] = '\0';
storage_file_close(file);
char* line = buf;
while(line && *line && app->target_count < TAGTINKER_MAX_TARGETS) {
char* nl = strchr(line, '\n');
if(nl) *nl = '\0';
if(*line) {
char* sep = strchr(line, '|');
if(sep) *sep = '\0';
if(tagtinker_barcode_to_plid(line, app->targets[app->target_count].plid)) {
TagTinkerTarget* target = &app->targets[app->target_count];
strncpy(target->barcode, line, TAGTINKER_BC_LEN);
target->barcode[TAGTINKER_BC_LEN] = '\0';
memset(target->name, 0, sizeof(target->name));
if(sep && *(sep + 1)) {
strncpy(target->name, sep + 1, TAGTINKER_TARGET_NAME_LEN);
target->name[TAGTINKER_TARGET_NAME_LEN] = '\0';
} else {
tagtinker_target_set_default_name(app, target);
}
tagtinker_target_refresh_profile(target);
app->target_count++;
}
}
line = nl ? nl + 1 : NULL;
}
}
storage_file_free(file);
furi_record_close(RECORD_STORAGE);
}
bool tagtinker_targets_save(const TagTinkerApp* app) {
Storage* storage = furi_record_open(RECORD_STORAGE);
storage_common_mkdir(storage, APP_DATA_PATH(""));
File* file = storage_file_alloc(storage);
bool ok = false;
if(storage_file_open(file, APP_DATA_PATH("targets.txt"), FSAM_WRITE, FSOM_CREATE_ALWAYS)) {
ok = true;
for(uint8_t i = 0; i < app->target_count; i++) {
char line[64];
int len = snprintf(
line,
sizeof(line),
"%s|%s\n",
app->targets[i].barcode,
app->targets[i].name);
if(len <= 0 || !storage_file_write(file, line, (uint16_t)len)) {
ok = false;
break;
}
}
storage_file_close(file);
}
storage_file_free(file);
furi_record_close(RECORD_STORAGE);
return ok;
}
void tagtinker_recents_load(TagTinkerApp* app) {
app->recent_count = 0;
Storage* storage = furi_record_open(RECORD_STORAGE);
File* file = storage_file_alloc(storage);
if(storage_file_open(file, APP_DATA_PATH("recents.txt"), FSAM_READ, FSOM_OPEN_EXISTING)) {
char buf[1024];
uint16_t read = storage_file_read(file, buf, sizeof(buf) - 1);
buf[read] = '\0';
storage_file_close(file);
char* line = buf;
while(line && *line && app->recent_count < TAGTINKER_MAX_PRESETS) {
char* nl = strchr(line, '\n');
if(nl) *nl = '\0';
unsigned w, h, pg, inv, clr, pad, sig;
/* Parse current format: w|h|pg|inv|clr|pad|sig|text.
Older saved entries omitted sig and are kept on the current mode. */
int parsed =
sscanf(line, "%u|%u|%u|%u|%u|%u|%u|", &w, &h, &pg, &inv, &clr, &pad, &sig);
if(parsed >= 6) {
char* p = line;
int pipes = 0;
int wanted_pipes = (parsed >= 7) ? 7 : 6;
while(*p && pipes < wanted_pipes) {
if(*p == '|') pipes++;
p++;
}
if(pipes == wanted_pipes) {
uint8_t idx = app->recent_count++;
app->recents[idx].width = (uint16_t)w;
app->recents[idx].height = (uint16_t)h;
app->recents[idx].page = (uint8_t)pg;
app->recents[idx].invert = (inv != 0);
app->recents[idx].color_clear = (clr != 0);
app->recents[idx].padding = (uint8_t)pad;
app->recents[idx].signal_mode = (uint8_t)TagTinkerSignalPP4;
strncpy(app->recents[idx].text, p, TAGTINKER_PRESET_TEXT_LEN - 1);
app->recents[idx].text[TAGTINKER_PRESET_TEXT_LEN - 1] = '\0';
}
}
line = nl ? nl + 1 : NULL;
}
}
storage_file_free(file);
furi_record_close(RECORD_STORAGE);
}
bool tagtinker_recents_save(const TagTinkerApp* app) {
Storage* storage = furi_record_open(RECORD_STORAGE);
storage_common_mkdir(storage, APP_DATA_PATH(""));
File* file = storage_file_alloc(storage);
bool ok = false;
if(storage_file_open(file, APP_DATA_PATH("recents.txt"), FSAM_WRITE, FSOM_CREATE_ALWAYS)) {
ok = true;
for(uint8_t i = 0; i < app->recent_count; i++) {
char line[128];
int len = snprintf(
line,
sizeof(line),
"%u|%u|%u|%d|%d|%u|%u|%s\n",
app->recents[i].width,
app->recents[i].height,
app->recents[i].page,
app->recents[i].invert ? 1 : 0,
app->recents[i].color_clear ? 1 : 0,
app->recents[i].padding,
(uint8_t)TagTinkerSignalPP4,
app->recents[i].text);
if(len <= 0 || !storage_file_write(file, line, (uint16_t)len)) {
ok = false;
break;
}
}
storage_file_close(file);
}
storage_file_free(file);
furi_record_close(RECORD_STORAGE);
return ok;
}
void tagtinker_recents_add(TagTinkerApp* app, const char* text) {
if(!app || !text || !*text) return;
/* Check if already in recents (move to top if so) */
int8_t existing_idx = -1;
for(uint8_t i = 0; i < app->recent_count; i++) {
if(strcmp(app->recents[i].text, text) == 0 && app->recents[i].width == app->esl_width &&
app->recents[i].height == app->esl_height) {
existing_idx = (int8_t)i;
break;
}
}
if(existing_idx >= 0) {
/* Move to front */
if(existing_idx > 0) {
uint16_t width = app->recents[existing_idx].width;
uint16_t height = app->recents[existing_idx].height;
uint8_t page = app->recents[existing_idx].page;
bool invert = app->recents[existing_idx].invert;
bool color_clear = app->recents[existing_idx].color_clear;
uint8_t padding = app->recents[existing_idx].padding;
char text_copy[TAGTINKER_PRESET_TEXT_LEN];
strncpy(text_copy, app->recents[existing_idx].text, TAGTINKER_PRESET_TEXT_LEN);
memmove(&app->recents[1], &app->recents[0], sizeof(app->recents[0]) * (size_t)existing_idx);
app->recents[0].width = width;
app->recents[0].height = height;
app->recents[0].page = page;
app->recents[0].invert = invert;
app->recents[0].color_clear = color_clear;
app->recents[0].padding = padding;
app->recents[0].signal_mode = (uint8_t)TagTinkerSignalPP4;
strncpy(app->recents[0].text, text_copy, TAGTINKER_PRESET_TEXT_LEN);
}
} else {
/* New entry, shift others */
if(app->recent_count < TAGTINKER_MAX_PRESETS) {
app->recent_count++;
}
memmove(&app->recents[1], &app->recents[0], sizeof(app->recents[0]) * (size_t)(app->recent_count - 1));
app->recents[0].width = app->esl_width;
app->recents[0].height = app->esl_height;
app->recents[0].page = app->img_page;
app->recents[0].invert = app->invert_text;
app->recents[0].color_clear = app->color_clear;
app->recents[0].padding = app->text_padding_pct;
app->recents[0].signal_mode = (uint8_t)TagTinkerSignalPP4;
strncpy(app->recents[0].text, text, TAGTINKER_PRESET_TEXT_LEN - 1);
app->recents[0].text[TAGTINKER_PRESET_TEXT_LEN - 1] = '\0';
}
tagtinker_recents_save(app);
}
static void app_free(TagTinkerApp* app) {
furi_assert(app);
tagtinker_free_frame_sequence(app);
/* Tear down WiFi link if it was lazily allocated. */
if(app->wifi) {
extern void tagtinker_wifi_free(void* w);
tagtinker_wifi_free(app->wifi);
app->wifi = NULL;
}
free(app->wifi_plugins);
app->wifi_plugins = NULL;
/* Views */
view_dispatcher_remove_view(app->view_dispatcher, TagTinkerViewSubmenu);
submenu_free(app->submenu);
view_dispatcher_remove_view(app->view_dispatcher, TagTinkerViewVarItemList);
variable_item_list_free(app->var_item_list);
view_dispatcher_remove_view(app->view_dispatcher, TagTinkerViewTextInput);
text_input_free(app->text_input);
view_dispatcher_remove_view(app->view_dispatcher, TagTinkerViewPopup);
popup_free(app->popup);
view_dispatcher_remove_view(app->view_dispatcher, TagTinkerViewWidget);
widget_free(app->widget);
view_dispatcher_remove_view(app->view_dispatcher, TagTinkerViewNumlock);
numlock_input_free(app->numlock);
view_dispatcher_remove_view(app->view_dispatcher, TagTinkerViewTextBox);
text_box_free(app->text_box);
view_dispatcher_remove_view(app->view_dispatcher, TagTinkerViewWarning);
view_free(app->warning_view);
view_dispatcher_remove_view(app->view_dispatcher, TagTinkerViewTransmit);
view_free(app->transmit_view);
view_dispatcher_remove_view(app->view_dispatcher, TagTinkerViewAbout);
view_free(app->about_view);
view_dispatcher_free(app->view_dispatcher);
scene_manager_free(app->scene_manager);
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);
if(app->bt) {
furi_record_close(RECORD_BT);
}
free(app);
}
static bool tagtinker_try_consume_web_job(TagTinkerApp* app) {
if(!app) return false;
Storage* storage = furi_record_open(RECORD_STORAGE);
File* file = storage_file_alloc(storage);
bool ok = false;
if(storage_file_open(file, TAGTINKER_WEB_JOB_PATH, FSAM_READ, FSOM_OPEN_EXISTING)) {
char buf[512];
uint16_t read = storage_file_read(file, buf, sizeof(buf) - 1U);
buf[read] = '\0';
storage_file_close(file);
storage_common_remove(storage, TAGTINKER_WEB_JOB_PATH);
char barcode[TAGTINKER_BC_LEN + 1] = {0};
char image_path[TAGTINKER_IMAGE_PATH_LEN + 1] = {0};
unsigned width = 0U;
unsigned height = 0U;
unsigned page = 0U;
if(
sscanf(
buf,
"%17[^|]|%u|%u|%u|%255[^\r\n]",
barcode,
&width,
&height,
&page,
image_path) == 5) {
int8_t target_index = tagtinker_ensure_target(app, barcode);
if(target_index >= 0) {
tagtinker_select_target(app, (uint8_t)target_index);
app->img_page = (uint8_t)page;
app->draw_x = 0U;
app->draw_y = 0U;
app->color_clear = false;
tagtinker_prepare_bmp_tx(
app,
app->targets[target_index].plid,
image_path,
(uint16_t)width,
(uint16_t)height,
(uint8_t)page);
app->tx_spam = false;
ok = true;
}
}
}
storage_file_free(file);
furi_record_close(RECORD_STORAGE);
return ok;
}
static TagTinkerApp* app_alloc(void) {
TagTinkerApp* app = malloc(sizeof(TagTinkerApp));
memset(app, 0, sizeof(TagTinkerApp));
/* Defaults */
app->page = 0;
app->duration = 15;
app->repeats = 200;
app->draw_x = 0;
app->draw_y = 0;
app->img_page = 1;
app->esl_width = 200;
app->esl_height = 80;
app->color_clear = false;
app->invert_text = false;
app->text_padding_pct = 0;
strcpy(app->text_input_buf, "TagTinker");
app->selected_target = -1;
/* Ensure the dropped-image folder exists so the user can pre-stage BMPs
* prepared with the web image preparer (web-image-prep/index.html). */
{
Storage* storage = furi_record_open(RECORD_STORAGE);
storage_common_mkdir(storage, APP_DATA_PATH(""));
storage_common_mkdir(storage, APP_DATA_PATH("dropped"));
furi_record_close(RECORD_STORAGE);
}
tagtinker_settings_load(app);
tagtinker_targets_load(app);
tagtinker_recents_load(app);
app->gui = furi_record_open(RECORD_GUI);
app->notifications = furi_record_open(RECORD_NOTIFICATION);
app->dialogs = furi_record_open(RECORD_DIALOGS);
app->bt = furi_record_open(RECORD_BT);
/* Momentum safety: Ensure radio is idle on startup */
bt_disconnect(app->bt);
bt_profile_restore_default(app->bt);
app->view_dispatcher = view_dispatcher_alloc();
app->scene_manager = scene_manager_alloc(&tagtinker_scene_handlers, app);
view_dispatcher_set_event_callback_context(app->view_dispatcher, app);
view_dispatcher_set_custom_event_callback(app->view_dispatcher, custom_event_cb);
view_dispatcher_set_navigation_event_callback(app->view_dispatcher, navigation_cb);
view_dispatcher_set_tick_event_callback(app->view_dispatcher, tick_cb, 50);
view_dispatcher_attach_to_gui(app->view_dispatcher, app->gui, ViewDispatcherTypeFullscreen);
/* Views */
app->submenu = submenu_alloc();
view_dispatcher_add_view(app->view_dispatcher, TagTinkerViewSubmenu, submenu_get_view(app->submenu));
app->var_item_list = variable_item_list_alloc();
view_dispatcher_add_view(app->view_dispatcher, TagTinkerViewVarItemList, variable_item_list_get_view(app->var_item_list));
app->text_input = text_input_alloc();
view_dispatcher_add_view(app->view_dispatcher, TagTinkerViewTextInput, text_input_get_view(app->text_input));
app->popup = popup_alloc();
view_dispatcher_add_view(app->view_dispatcher, TagTinkerViewPopup, popup_get_view(app->popup));
app->widget = widget_alloc();
view_dispatcher_add_view(app->view_dispatcher, TagTinkerViewWidget, widget_get_view(app->widget));
app->numlock = numlock_input_alloc();
view_dispatcher_add_view(app->view_dispatcher, TagTinkerViewNumlock, numlock_input_get_view(app->numlock));
app->text_box = text_box_alloc();
view_dispatcher_add_view(app->view_dispatcher, TagTinkerViewTextBox, text_box_get_view(app->text_box));
app->warning_view = view_alloc();
view_dispatcher_add_view(app->view_dispatcher, TagTinkerViewWarning, app->warning_view);
app->transmit_view = view_alloc();
view_dispatcher_add_view(app->view_dispatcher, TagTinkerViewTransmit, app->transmit_view);
app->about_view = view_alloc();
view_dispatcher_add_view(app->view_dispatcher, TagTinkerViewAbout, app->about_view);
/* TX Thread */
app->tx_thread = furi_thread_alloc();
furi_thread_set_name(app->tx_thread, "TagTinkerTx");
furi_thread_set_stack_size(app->tx_thread, 4096);
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;
}
int32_t tagtinker_app_main(void* p) {
UNUSED(p);
TagTinkerApp* app = app_alloc();
bool web_job_ready = tagtinker_try_consume_web_job(app);
if(web_job_ready) {
scene_manager_next_scene(app->scene_manager, TagTinkerSceneTransmit);
} else if(app->show_startup_warning) {
scene_manager_next_scene(app->scene_manager, TagTinkerSceneWarning);
} else {
scene_manager_next_scene(app->scene_manager, TagTinkerSceneMainMenu);
}
view_dispatcher_run(app->view_dispatcher);
app_free(app);
return 0;
}
@@ -0,0 +1,331 @@
/*
* App state.
*/
#pragma once
#include <furi.h>
#include <gui/gui.h>
#include <gui/view_dispatcher.h>
#include <gui/view.h>
#include <gui/scene_manager.h>
#include <gui/modules/submenu.h>
#include <gui/modules/text_input.h>
#include <gui/modules/variable_item_list.h>
#include <gui/modules/popup.h>
#include <gui/modules/widget.h>
#include <gui/modules/text_box.h>
#include <dialogs/dialogs.h>
#include <notification/notification_messages.h>
#include <storage/storage.h>
#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.1"
#define TAGTINKER_BC_LEN 17
#define TAGTINKER_HEX_LEN 64
#define TAGTINKER_MAX_TARGETS 16
#define TAGTINKER_TARGET_NAME_LEN 16
#define TAGTINKER_MAX_PRESETS 6
#define TAGTINKER_MAX_SYNCED_IMAGES 24
#define TAGTINKER_PRESET_TEXT_LEN 32
#define TAGTINKER_IMAGE_PATH_LEN 255
#define TAGTINKER_SYNC_JOB_ID_LEN 32
typedef enum {
TagTinkerTxModeNone = 0,
TagTinkerTxModeTextImage,
TagTinkerTxModeBmpImage,
} TagTinkerTxMode;
typedef enum {
TagTinkerSignalPP4 = 0,
} TagTinkerSignalMode;
typedef struct {
TagTinkerTxMode mode;
uint8_t plid[4];
uint8_t page;
uint16_t width;
uint16_t height;
uint16_t pos_x;
uint16_t pos_y;
char image_path[TAGTINKER_IMAGE_PATH_LEN + 1];
} TagTinkerImageTxJob;
typedef struct {
char job_id[TAGTINKER_SYNC_JOB_ID_LEN + 1];
char barcode[TAGTINKER_BC_LEN + 1];
uint16_t width;
uint16_t height;
uint8_t page;
/* True when this BMP was authored for a different resolution than the
* current target, meaning the transmitter will rescale it on the fly. */
bool resampled;
char image_path[TAGTINKER_IMAGE_PATH_LEN + 1];
} TagTinkerSyncedImage;
typedef enum {
TagTinkerTextInputNewText = 0,
TagTinkerTextInputKeepText = 1,
TagTinkerTextInputRenameTarget = 2,
} TagTinkerTextInputMode;
/* Views */
typedef enum {
TagTinkerViewSubmenu,
TagTinkerViewVarItemList,
TagTinkerViewTextInput,
TagTinkerViewPopup,
TagTinkerViewWidget,
TagTinkerViewNumlock,
TagTinkerViewTextBox,
TagTinkerViewTargetActions,
TagTinkerViewWarning,
TagTinkerViewTransmit,
TagTinkerViewAbout,
} TagTinkerView;
/* Saved ESL target */
typedef struct {
char name[TAGTINKER_TARGET_NAME_LEN + 1];
char barcode[TAGTINKER_BC_LEN + 1];
uint8_t plid[4];
TagTinkerTagProfile profile;
} TagTinkerTarget;
struct TagTinkerApp {
/* GUI */
Gui* gui;
ViewDispatcher* view_dispatcher;
SceneManager* scene_manager;
NotificationApp* notifications;
DialogsApp* dialogs;
/* Views */
Submenu* submenu;
VariableItemList* var_item_list;
TextInput* text_input;
Popup* popup;
Widget* widget;
NumlockInput* numlock;
TextBox* text_box;
View* target_actions_view;
View* warning_view;
View* transmit_view;
View* about_view;
bool warning_view_allocated;
bool transmit_view_allocated;
bool about_view_allocated;
/* TX state */
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;
uint16_t duration;
uint16_t repeats;
bool forever;
bool tx_spam;
/* Current target */
char barcode[TAGTINKER_BC_LEN + 1];
uint8_t plid[4];
bool barcode_valid;
int8_t selected_target; /* -1 = none */
/* Saved targets */
TagTinkerTarget targets[TAGTINKER_MAX_TARGETS];
uint8_t target_count;
/* Text to push */
char text_input_buf[64];
/* ESL display size for current target */
uint16_t esl_width;
uint16_t esl_height;
/* Frame buffer */
uint8_t frame_buf[TAGTINKER_MAX_FRAME_SIZE];
size_t frame_len;
/* Multi-frame sequence */
uint8_t** frame_sequence;
size_t* frame_lengths;
uint16_t* frame_repeats;
size_t frame_seq_count;
bool invert_text;
bool color_clear;
uint8_t text_padding_pct;
/* Saved recents */
struct {
uint16_t width;
uint16_t height;
uint8_t page;
bool invert;
bool color_clear;
uint8_t padding;
uint8_t signal_mode;
char text[TAGTINKER_PRESET_TEXT_LEN];
} recents[TAGTINKER_MAX_PRESETS];
uint8_t recent_count;
TagTinkerSyncedImage synced_images[TAGTINKER_MAX_SYNCED_IMAGES];
uint8_t synced_image_count;
/* Image settings */
uint8_t img_page;
uint16_t draw_x;
uint16_t draw_y;
uint16_t draw_width;
uint16_t draw_height;
TagTinkerImageTxJob image_tx_job;
TagTinkerCompressionMode compression_mode;
uint8_t data_frame_repeats;
TagTinkerSignalMode signal_mode;
bool show_startup_warning;
/* Indicates which mode triggered raw cmd (0=broadcast, 1=targeted) */
uint8_t raw_mode;
/* Browser BLE sync state */
Bt* bt;
FuriHalBleProfileBase* ble_serial;
BtStatus ble_status;
bool ble_sync_active;
bool ble_sync_start_pending;
bool ble_serial_configured;
uint32_t ble_total_rx;
uint8_t ble_last_bytes[3];
bool ble_sync_auto_send_pending;
uint8_t ble_sync_auto_send_delay;
uint16_t ble_synced_lines;
char ble_status_text[32];
char ble_rx_line[1024];
char ble_rx_pending_line[1024];
uint16_t ble_rx_len;
bool ble_rx_pending_ready;
bool ble_sync_job_active;
char ble_sync_job_id[TAGTINKER_SYNC_JOB_ID_LEN + 1];
char ble_sync_barcode[TAGTINKER_BC_LEN + 1];
char ble_sync_temp_path[TAGTINKER_IMAGE_PATH_LEN + 1];
char ble_sync_final_path[TAGTINKER_IMAGE_PATH_LEN + 1];
char ble_sync_last_job_id[TAGTINKER_SYNC_JOB_ID_LEN + 1];
uint32_t ble_sync_expected_bytes;
uint32_t ble_sync_received_bytes;
uint16_t ble_sync_last_chunk;
File* ble_sync_file;
uint16_t ble_sync_last_completed_chunks;
bool ble_sync_compact_protocol;
bool ble_sync_last_compact_protocol;
int8_t ble_sync_ready_target;
/* ---- WiFi Plugins (ESP32 dev board) -------------------------------- */
/* Opaque handle (TagTinkerWifi*) - declared in wifi/tagtinker_wifi.h.
* Stored as void* here so this header doesn't pull in expansion/serial
* deps for unrelated translation units. */
void* wifi;
/* WiFi link state mirrored from the ESP. */
uint8_t wifi_link_state; /* TT_WIFI_* */
int8_t wifi_rssi;
char wifi_ssid[33];
char wifi_ip[20];
char wifi_creds_ssid[33]; /* used by setup scene before sending */
char wifi_creds_pwd[65];
/* Plugin discovery cache. Up to TT_WIFI_MAX_FAP_PLUGINS slots. */
void* wifi_plugins; /* TagTinkerWifiPlugin[TT_WIFI_MAX_FAP_PLUGINS], heap-alloced */
uint8_t wifi_plugin_count;
bool wifi_plugins_loading;
int8_t wifi_selected_plugin;
/* Per-run state. */
char wifi_progress_msg[64];
uint8_t wifi_progress_pct;
char wifi_last_error[80];
bool wifi_run_in_flight;
bool wifi_result_ready;
/* Param values being collected by the run scene; one slot per plugin
* param, holding the textual value the user picked (string for STRING,
* stringified int for INT, option name for ENUM, "0"/"1" for BOOL). */
char wifi_param_values[6][64];
};
/* Main menu items */
typedef enum {
TagTinkerMenuBroadcast,
TagTinkerMenuTargetESL,
TagTinkerMenuWifiPlugins,
TagTinkerMenuAbout,
} TagTinkerMainMenuItem;
/* Broadcast menu items */
typedef enum {
TagTinkerBroadcastFlipPage,
TagTinkerBroadcastDebugScreen,
} TagTinkerBroadcastMenuItem;
/* Target action items */
typedef enum {
TagTinkerTargetDetails,
TagTinkerTargetRename,
TagTinkerTargetPushText,
TagTinkerTargetPushSyncedImage,
TagTinkerTargetWifiPlugins,
TagTinkerTargetDeleteSyncedImages,
TagTinkerTargetPingFlash,
TagTinkerTargetDeleteTag,
} TagTinkerTargetActionItem;
void tagtinker_target_refresh_profile(TagTinkerTarget* target);
void tagtinker_target_set_default_name(TagTinkerApp* app, TagTinkerTarget* target);
bool tagtinker_target_supports_graphics(const TagTinkerTarget* target);
bool tagtinker_target_supports_accent(const TagTinkerTarget* target);
const char* tagtinker_profile_kind_label(TagTinkerTagKind kind);
const char* tagtinker_profile_color_label(TagTinkerTagColor color);
int8_t tagtinker_find_target_by_barcode(const TagTinkerApp* app, const char* barcode);
int8_t tagtinker_ensure_target(TagTinkerApp* app, const char* barcode);
bool tagtinker_find_latest_synced_image(
const TagTinkerApp* app,
const char* barcode,
TagTinkerSyncedImage* image);
size_t tagtinker_delete_synced_images_for_barcode(TagTinkerApp* app, const char* barcode);
bool tagtinker_delete_target(TagTinkerApp* app, uint8_t index);
void tagtinker_free_frame_sequence(TagTinkerApp* app);
uint16_t tagtinker_pick_chunk_height(uint16_t width, bool color_clear);
void tagtinker_prepare_text_tx(TagTinkerApp* app, const uint8_t plid[4]);
void tagtinker_prepare_bmp_tx(
TagTinkerApp* app,
const uint8_t plid[4],
const char* image_path,
uint16_t width,
uint16_t height,
uint8_t page);
void tagtinker_select_target(TagTinkerApp* app, uint8_t index);
void tagtinker_settings_load(TagTinkerApp* app);
bool tagtinker_settings_save(const TagTinkerApp* app);
void tagtinker_targets_load(TagTinkerApp* app);
bool tagtinker_targets_save(const TagTinkerApp* app);
void tagtinker_recents_load(TagTinkerApp* app);
bool tagtinker_recents_save(const TagTinkerApp* app);
void tagtinker_recents_add(TagTinkerApp* app, const char* text);
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

@@ -0,0 +1,211 @@
/*
* Barcode input view.
*/
#include "numlock_input.h"
#include <gui/elements.h>
#include <furi.h>
#include <string.h>
#define NUM_DIGITS 16
#define CHAR_W 6
#define GROUP_SIZE 4
#define GROUP_GAP 5
#define PREFIX_W 10
typedef struct {
char prefix;
uint8_t digits[NUM_DIGITS];
uint8_t cursor;
} NumlockModel;
static uint8_t prefix_x(void) {
return 4;
}
static uint8_t digit_x(uint8_t i) {
uint8_t groups = i / GROUP_SIZE;
/* Keep the full code centered across the 128 px canvas. */
return 4 + 8 + i * CHAR_W + groups * GROUP_GAP;
}
static void numlock_draw(Canvas* canvas, void* model_v) {
NumlockModel* m = model_v;
canvas_clear(canvas);
canvas_set_color(canvas, ColorBlack);
canvas_draw_box(canvas, 0, 0, 128, 12);
canvas_set_color(canvas, ColorWhite);
canvas_set_font(canvas, FontSecondary);
canvas_draw_str_aligned(canvas, 64, 6, AlignCenter, AlignCenter, "SET BARCODE");
canvas_set_color(canvas, ColorBlack);
int frame_y = 19;
int frame_h = 24;
canvas_draw_rframe(canvas, 1, frame_y, 126, frame_h, 2);
canvas_draw_line(canvas, 2, frame_y+1, 125, frame_y+1);
const uint8_t baseline = 36;
canvas_set_font(canvas, FontPrimary);
char prefix[2] = {m->prefix, '\0'};
if(m->cursor == 0) {
uint8_t x = prefix_x();
canvas_draw_box(canvas, x - 1, frame_y + 3, CHAR_W + 2, frame_h - 6);
canvas_set_color(canvas, ColorWhite);
canvas_draw_str(canvas, x, baseline, prefix);
canvas_set_color(canvas, ColorBlack);
uint8_t cx = x + CHAR_W / 2 - 1;
canvas_draw_line(canvas, cx, frame_y - 4, cx - 2, frame_y - 2);
canvas_draw_line(canvas, cx, frame_y - 4, cx + 2, frame_y - 2);
canvas_draw_line(canvas, cx, frame_y - 4, cx, frame_y - 1);
canvas_draw_line(canvas, cx, frame_y + frame_h + 3, cx - 2, frame_y + frame_h + 1);
canvas_draw_line(canvas, cx, frame_y + frame_h + 3, cx + 2, frame_y + frame_h + 1);
canvas_draw_line(canvas, cx, frame_y + frame_h + 3, cx, frame_y + frame_h);
} else {
canvas_draw_str(canvas, prefix_x(), baseline, prefix);
}
for(uint8_t i = 0; i < NUM_DIGITS; i++) {
uint8_t x = digit_x(i);
char ch[2] = {'0' + m->digits[i], '\0'};
if((i + 1) == m->cursor) {
canvas_draw_box(canvas, x - 1, frame_y + 3, CHAR_W + 2, frame_h - 6);
canvas_set_color(canvas, ColorWhite);
canvas_draw_str(canvas, x, baseline, ch);
canvas_set_color(canvas, ColorBlack);
uint8_t cx = x + CHAR_W / 2 - 1;
canvas_draw_line(canvas, cx, frame_y - 4, cx - 2, frame_y - 2);
canvas_draw_line(canvas, cx, frame_y - 4, cx + 2, frame_y - 2);
canvas_draw_line(canvas, cx, frame_y - 4, cx, frame_y - 1);
canvas_draw_line(canvas, cx, frame_y + frame_h + 3, cx - 2, frame_y + frame_h + 1);
canvas_draw_line(canvas, cx, frame_y + frame_h + 3, cx + 2, frame_y + frame_h + 1);
canvas_draw_line(canvas, cx, frame_y + frame_h + 3, cx, frame_y + frame_h);
} else {
canvas_draw_str(canvas, x, baseline, ch);
}
}
canvas_set_font(canvas, FontSecondary);
canvas_draw_str(canvas, 2, 59, "<\x12\x13> Sel");
canvas_draw_str(canvas, 45, 59, "^\x18\x19v Set");
canvas_draw_rbox(canvas, 92, 48, 34, 14, 2);
canvas_set_color(canvas, ColorWhite);
canvas_draw_str_aligned(canvas, 109, 55, AlignCenter, AlignCenter, "Hold OK");
canvas_set_color(canvas, ColorBlack);
}
static bool numlock_input(InputEvent* input, void* ctx) {
NumlockInput* numlock = ctx;
if(input->type != InputTypePress && input->type != InputTypeRepeat) return false;
bool consumed = false;
with_view_model(
numlock->view,
NumlockModel * m,
{
switch(input->key) {
case InputKeyUp:
if(m->cursor == 0) {
m->prefix = (m->prefix == 'Z') ? 'A' : (m->prefix + 1);
} else {
uint8_t digit_idx = m->cursor - 1;
m->digits[digit_idx] = (m->digits[digit_idx] + 1) % 10;
}
consumed = true;
break;
case InputKeyDown:
if(m->cursor == 0) {
m->prefix = (m->prefix == 'A') ? 'Z' : (m->prefix - 1);
} else {
uint8_t digit_idx = m->cursor - 1;
m->digits[digit_idx] = (m->digits[digit_idx] + 9) % 10;
}
consumed = true;
break;
case InputKeyLeft:
if(m->cursor > 0) m->cursor--;
consumed = true;
break;
case InputKeyRight:
if(m->cursor < NUM_DIGITS) m->cursor++;
consumed = true;
break;
case InputKeyOk: {
char barcode[18];
barcode[0] = m->prefix;
for(uint8_t i = 0; i < NUM_DIGITS; i++)
barcode[1 + i] = '0' + m->digits[i];
barcode[17] = '\0';
if(numlock->callback)
numlock->callback(numlock->callback_ctx, barcode);
consumed = true;
break;
}
default:
break;
}
},
consumed);
return consumed;
}
NumlockInput* numlock_input_alloc(void) {
NumlockInput* numlock = malloc(sizeof(NumlockInput));
numlock->view = view_alloc();
view_allocate_model(numlock->view, ViewModelTypeLocking, sizeof(NumlockModel));
view_set_draw_callback(numlock->view, numlock_draw);
view_set_input_callback(numlock->view, numlock_input);
view_set_context(numlock->view, numlock);
numlock->callback = NULL;
numlock->callback_ctx = NULL;
with_view_model(
numlock->view,
NumlockModel * m,
{
m->prefix = 'A';
memset(m->digits, 0, NUM_DIGITS);
m->digits[0] = 0;
m->cursor = 0;
},
true);
return numlock;
}
void numlock_input_free(NumlockInput* numlock) {
view_free(numlock->view);
free(numlock);
}
View* numlock_input_get_view(NumlockInput* numlock) {
return numlock->view;
}
void numlock_input_set_callback(NumlockInput* numlock, NumlockCallback cb, void* ctx) {
numlock->callback = cb;
numlock->callback_ctx = ctx;
}
void numlock_input_reset(NumlockInput* numlock) {
with_view_model(
numlock->view,
NumlockModel * m,
{
m->prefix = 'A';
memset(m->digits, 0, NUM_DIGITS);
m->digits[0] = 0;
m->cursor = 0;
},
true);
}
@@ -0,0 +1,21 @@
/*
* Barcode input view.
*/
#pragma once
#include <gui/view.h>
typedef void (*NumlockCallback)(void* ctx, const char* barcode);
typedef struct {
View* view;
NumlockCallback callback;
void* callback_ctx;
} NumlockInput;
NumlockInput* numlock_input_alloc(void);
void numlock_input_free(NumlockInput* numlock);
View* numlock_input_get_view(NumlockInput* numlock);
void numlock_input_set_callback(NumlockInput* numlock, NumlockCallback cb, void* ctx);
void numlock_input_reset(NumlockInput* numlock);

Some files were not shown because too many files have changed in this diff Show More