mirror of
https://github.com/D4C1-Labs/Flipper-ARF.git
synced 2026-07-19 02:36:16 +00:00
Compare commits
18
Commits
dev-b34a73b0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a5c442503 | ||
|
|
90e1c2b850 | ||
|
|
b6d8d67c6e | ||
|
|
ac8909904e | ||
|
|
d4d8b0e646 | ||
|
|
36483ec07d | ||
|
|
807e74b7d6 | ||
|
|
f4fe2d48c1 | ||
|
|
bc59edff17 | ||
|
|
63d951dc38 | ||
|
|
f1422cb701 | ||
|
|
f7b04f1382 | ||
|
|
7286b5c529 | ||
|
|
6a49fd89a1 | ||
|
|
c4378c2e20 | ||
|
|
6e5ba9ec22 | ||
|
|
502570a18b | ||
|
|
e606c5b24f |
@@ -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
|
||||
@@ -15,6 +15,7 @@ typedef enum {
|
||||
SubGhzCustomEventSceneReceiverInfoTxStart,
|
||||
SubGhzCustomEventSceneReceiverInfoTxStop,
|
||||
SubGhzCustomEventSceneReceiverInfoSave,
|
||||
SubGhzCustomEventSceneReceiverInfoTxFullDpad,
|
||||
SubGhzCustomEventSceneSaveName,
|
||||
SubGhzCustomEventSceneSignalSettings,
|
||||
SubGhzCustomEventSceneSaveSuccess,
|
||||
|
||||
@@ -361,6 +361,49 @@ SubGhzTxRxStartTxState subghz_txrx_tx_start(SubGhzTxRx* instance, FlipperFormat*
|
||||
return ret;
|
||||
}
|
||||
|
||||
bool subghz_txrx_rebuild_from_fff(SubGhzTxRx* instance, FlipperFormat* flipper_format) {
|
||||
furi_assert(instance);
|
||||
furi_assert(flipper_format);
|
||||
|
||||
subghz_txrx_stop(instance);
|
||||
|
||||
bool rebuilt = false;
|
||||
FuriString* temp_str = furi_string_alloc();
|
||||
SubGhzTransmitter* transmitter = NULL;
|
||||
|
||||
do {
|
||||
if(!flipper_format_rewind(flipper_format)) {
|
||||
FURI_LOG_E(TAG, "Rewind error");
|
||||
break;
|
||||
}
|
||||
if(!flipper_format_read_string(flipper_format, "Protocol", temp_str)) {
|
||||
FURI_LOG_E(TAG, "Missing Protocol");
|
||||
break;
|
||||
}
|
||||
|
||||
transmitter =
|
||||
subghz_transmitter_alloc_init(instance->environment, furi_string_get_cstr(temp_str));
|
||||
if(!transmitter) {
|
||||
FURI_LOG_E(TAG, "Protocol not found");
|
||||
break;
|
||||
}
|
||||
|
||||
rebuilt =
|
||||
subghz_transmitter_deserialize(transmitter, flipper_format) == SubGhzProtocolStatusOk;
|
||||
if(!rebuilt) {
|
||||
FURI_LOG_E(TAG, "Protocol rebuild failed");
|
||||
break;
|
||||
}
|
||||
} while(false);
|
||||
|
||||
if(transmitter) {
|
||||
subghz_transmitter_free(transmitter);
|
||||
}
|
||||
furi_string_free(temp_str);
|
||||
|
||||
return rebuilt;
|
||||
}
|
||||
|
||||
void subghz_txrx_rx_start(SubGhzTxRx* instance) {
|
||||
furi_assert(instance);
|
||||
subghz_txrx_stop(instance);
|
||||
|
||||
@@ -105,6 +105,15 @@ void subghz_txrx_get_frequency_and_modulation(
|
||||
*/
|
||||
SubGhzTxRxStartTxState subghz_txrx_tx_start(SubGhzTxRx* instance, FlipperFormat* flipper_format);
|
||||
|
||||
/**
|
||||
* Rebuild protocol data without starting TX.
|
||||
*
|
||||
* @param instance Pointer to a SubGhzTxRx
|
||||
* @param flipper_format Pointer to a FlipperFormat
|
||||
* @return bool True when encoder deserialization updated protocol data successfully
|
||||
*/
|
||||
bool subghz_txrx_rebuild_from_fff(SubGhzTxRx* instance, FlipperFormat* flipper_format);
|
||||
|
||||
/**
|
||||
* Start RX CC1101
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "../subghz_i.h"
|
||||
|
||||
#include <lib/subghz/blocks/custom_btn.h>
|
||||
#include <flipper_format/flipper_format_i.h>
|
||||
|
||||
#include "applications/main/subghz/helpers/subghz_txrx_i.h"
|
||||
#include <lib/subghz/blocks/generic.h>
|
||||
@@ -20,6 +21,9 @@ void subghz_scene_receiver_info_callback(GuiButtonType result, InputType type, v
|
||||
} else if((result == GuiButtonTypeRight) && (type == InputTypeShort)) {
|
||||
view_dispatcher_send_custom_event(
|
||||
subghz->view_dispatcher, SubGhzCustomEventSceneReceiverInfoSave);
|
||||
} else if((result == GuiButtonTypeLeft) && (type == InputTypeShort)) {
|
||||
view_dispatcher_send_custom_event(
|
||||
subghz->view_dispatcher, SubGhzCustomEventSceneReceiverInfoTxFullDpad);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +33,6 @@ static bool subghz_scene_receiver_info_update_parser(void* context) {
|
||||
if(subghz_txrx_load_decoder_by_name_protocol(
|
||||
subghz->txrx,
|
||||
subghz_history_get_protocol_name(subghz->history, subghz->idx_menu_chosen))) {
|
||||
// we are trying to deserialize without checking for errors, since it is assumed that we just received this chignal
|
||||
subghz_protocol_decoder_base_deserialize(
|
||||
subghz_txrx_get_decoder(subghz->txrx),
|
||||
subghz_history_get_raw_data(subghz->history, subghz->idx_menu_chosen));
|
||||
@@ -37,7 +40,6 @@ static bool subghz_scene_receiver_info_update_parser(void* context) {
|
||||
SubGhzRadioPreset* preset =
|
||||
subghz_history_get_radio_preset(subghz->history, subghz->idx_menu_chosen);
|
||||
|
||||
//Edit TX power, if necessary.
|
||||
subghz_txrx_set_tx_power(preset->data, preset->data_size, subghz->tx_power);
|
||||
|
||||
subghz_txrx_set_preset(
|
||||
@@ -93,7 +95,7 @@ void subghz_scene_receiver_info_draw_widget(SubGhz* subghz) {
|
||||
subghz_scene_receiver_info_callback,
|
||||
subghz);
|
||||
}
|
||||
// Removed static check
|
||||
|
||||
if(subghz_txrx_protocol_is_transmittable(subghz->txrx, false)) {
|
||||
widget_add_button_element(
|
||||
subghz->widget,
|
||||
@@ -101,9 +103,14 @@ void subghz_scene_receiver_info_draw_widget(SubGhz* subghz) {
|
||||
"Send",
|
||||
subghz_scene_receiver_info_callback,
|
||||
subghz);
|
||||
widget_add_button_element(
|
||||
subghz->widget,
|
||||
GuiButtonTypeLeft,
|
||||
"Full",
|
||||
subghz_scene_receiver_info_callback,
|
||||
subghz);
|
||||
}
|
||||
} else {
|
||||
// [NO_DOLPHIN] widget_add_icon_element(subghz->widget, 83, 22, &I_WarningDolphinFlip_45x42);
|
||||
widget_add_string_element(
|
||||
subghz->widget, 13, 8, AlignLeft, AlignBottom, FontSecondary, "Error history parse.");
|
||||
}
|
||||
@@ -131,12 +138,7 @@ bool subghz_scene_receiver_info_on_event(void* context, SceneManagerEvent event)
|
||||
if(!subghz_scene_receiver_info_update_parser(subghz)) {
|
||||
return false;
|
||||
}
|
||||
//CC1101 Stop RX -> Start TX
|
||||
subghz_txrx_hopper_pause(subghz->txrx);
|
||||
// key concept: we start endless TX until user release OK button, and after this we send last
|
||||
// protocols repeats - this guarantee that one press OK will
|
||||
// be guarantee send the required minimum protocol data packets
|
||||
// for all of this we use subghz_block_generic_global.endless_tx in protocols _yield function.
|
||||
subghz->state_notifications = SubGhzNotificationStateTx;
|
||||
subghz_block_generic_global.endless_tx = true;
|
||||
if(!subghz_tx_start(
|
||||
@@ -146,37 +148,51 @@ bool subghz_scene_receiver_info_on_event(void* context, SceneManagerEvent event)
|
||||
subghz_txrx_hopper_unpause(subghz->txrx);
|
||||
subghz->state_notifications = SubGhzNotificationStateRx;
|
||||
subghz_block_generic_global.endless_tx = false;
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
|
||||
} else if(event.event == SubGhzCustomEventSceneReceiverInfoTxStop) {
|
||||
//CC1101 Stop Tx -> next tick event Start RX
|
||||
// user release OK
|
||||
// we switch off endless_tx - that mean protocols yield finish endless transmission,
|
||||
// send upload "repeat=xx" times, and after will be stoped by the tick event down in this code
|
||||
subghz->state_notifications = SubGhzNotificationStateTxWait;
|
||||
subghz_block_generic_global.endless_tx = false;
|
||||
|
||||
return true;
|
||||
|
||||
} else if(event.event == SubGhzCustomEventSceneReceiverInfoSave) {
|
||||
//CC1101 Stop RX -> Save
|
||||
subghz->state_notifications = SubGhzNotificationStateIDLE;
|
||||
subghz_txrx_hopper_set_state(subghz->txrx, SubGhzHopperStateOFF);
|
||||
|
||||
subghz_txrx_stop(subghz->txrx);
|
||||
if(!subghz_scene_receiver_info_update_parser(subghz)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if(subghz_txrx_protocol_is_serializable(subghz->txrx)) {
|
||||
subghz_file_name_clear(subghz);
|
||||
|
||||
subghz->save_datetime =
|
||||
subghz_history_get_datetime(subghz->history, subghz->idx_menu_chosen);
|
||||
subghz->save_datetime_set = true;
|
||||
scene_manager_next_scene(subghz->scene_manager, SubGhzSceneSaveName);
|
||||
}
|
||||
return true;
|
||||
|
||||
} else if(event.event == SubGhzCustomEventSceneReceiverInfoTxFullDpad) {
|
||||
if(!subghz_scene_receiver_info_update_parser(subghz)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
FlipperFormat* fff_history =
|
||||
subghz_history_get_raw_data(subghz->history, subghz->idx_menu_chosen);
|
||||
FlipperFormat* fff_data = subghz_txrx_get_fff_data(subghz->txrx);
|
||||
|
||||
Stream* src = flipper_format_get_raw_stream(fff_history);
|
||||
Stream* dst = flipper_format_get_raw_stream(fff_data);
|
||||
|
||||
stream_seek(src, 0, StreamOffsetFromStart);
|
||||
stream_clean(dst);
|
||||
stream_copy_full(src, dst);
|
||||
stream_seek(dst, 0, StreamOffsetFromStart);
|
||||
|
||||
scene_manager_next_scene(subghz->scene_manager, SubGhzSceneTransmitter);
|
||||
return true;
|
||||
}
|
||||
|
||||
} else if(event.type == SceneManagerEventTypeTick) {
|
||||
if(subghz_txrx_hopper_get_state(subghz->txrx) != SubGhzHopperStateOFF) {
|
||||
subghz_txrx_hopper_update(subghz->txrx, subghz->last_settings->hopping_threshold);
|
||||
@@ -193,18 +209,15 @@ bool subghz_scene_receiver_info_on_event(void* context, SceneManagerEvent event)
|
||||
subghz->state_notifications = SubGhzNotificationStateRx;
|
||||
break;
|
||||
case SubGhzNotificationStateTxWait:
|
||||
// we wait until hardware TX finished and after stop TX and start RX, else just blink led
|
||||
if(!subghz_devices_is_async_complete_tx(subghz->txrx->radio_device)) {
|
||||
notification_message(subghz->notifications, &sequence_blink_magenta_10);
|
||||
} else {
|
||||
subghz_txrx_stop(subghz->txrx);
|
||||
// update screen
|
||||
widget_reset(subghz->widget);
|
||||
subghz_scene_receiver_info_draw_widget(subghz);
|
||||
|
||||
subghz->state_notifications = SubGhzNotificationStateIDLE;
|
||||
|
||||
if(!scene_manager_has_previous_scene(subghz->scene_manager, SubGhzSceneDecodeRAW)) {
|
||||
if(!scene_manager_has_previous_scene(
|
||||
subghz->scene_manager, SubGhzSceneDecodeRAW)) {
|
||||
subghz_txrx_rx_start(subghz->txrx);
|
||||
subghz_txrx_hopper_unpause(subghz->txrx);
|
||||
if(!subghz_history_get_text_space_left(subghz->history, NULL)) {
|
||||
@@ -222,7 +235,6 @@ bool subghz_scene_receiver_info_on_event(void* context, SceneManagerEvent event)
|
||||
|
||||
void subghz_scene_receiver_info_on_exit(void* context) {
|
||||
SubGhz* subghz = context;
|
||||
|
||||
widget_reset(subghz->widget);
|
||||
subghz_txrx_reset_dynamic_and_custom_btns(subghz->txrx);
|
||||
}
|
||||
|
||||
@@ -110,6 +110,7 @@ bool subghz_scene_save_name_on_event(void* context, SceneManagerEvent event) {
|
||||
} else if(event.type == SceneManagerEventTypeCustom) {
|
||||
if(event.event == SubGhzCustomEventSceneSaveName) {
|
||||
if(strcmp(subghz->file_name_tmp, "") != 0) {
|
||||
furi_string_reset(subghz->error_str);
|
||||
furi_string_cat_printf(
|
||||
subghz->file_path,
|
||||
"/%s%s",
|
||||
|
||||
@@ -12,8 +12,14 @@ void subghz_scene_save_success_on_enter(void* context) {
|
||||
// Setup view
|
||||
Popup* popup = subghz->popup;
|
||||
// [NO_DOLPHIN] popup_set_icon(popup, 36, 5, &I_DolphinSaved_92x58);
|
||||
popup_set_header(popup, "Saved", 15, 19, AlignLeft, AlignBottom);
|
||||
popup_set_timeout(popup, 1500);
|
||||
if(furi_string_size(subghz->error_str)) {
|
||||
popup_set_header(popup, "Saved", 15, 4, AlignLeft, AlignTop);
|
||||
popup_set_text(popup, furi_string_get_cstr(subghz->error_str), 4, 18, AlignLeft, AlignTop);
|
||||
popup_set_timeout(popup, 2500);
|
||||
} else {
|
||||
popup_set_header(popup, "Saved", 15, 19, AlignLeft, AlignBottom);
|
||||
popup_set_timeout(popup, 1500);
|
||||
}
|
||||
popup_set_context(popup, subghz);
|
||||
popup_set_callback(popup, subghz_scene_save_success_popup_callback);
|
||||
popup_enable_timeout(popup);
|
||||
@@ -72,4 +78,5 @@ void subghz_scene_save_success_on_exit(void* context) {
|
||||
Popup* popup = subghz->popup;
|
||||
|
||||
popup_reset(popup);
|
||||
furi_string_reset(subghz->error_str);
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
enum SubmenuIndex {
|
||||
SubmenuIndexEmulate,
|
||||
SubmenuIndexSignalSettings,
|
||||
SubmenuIndexPsaDecrypt,
|
||||
SubmenuIndexEdit,
|
||||
SubmenuIndexDelete,
|
||||
SubmenuIndexSignalSettings,
|
||||
SubmenuIndexCounterBf, /* <-- comma was missing here */
|
||||
SubmenuIndexCarEmulateSettings,
|
||||
};
|
||||
@@ -20,17 +20,20 @@ void subghz_scene_saved_menu_on_enter(void* context) {
|
||||
|
||||
FlipperFormat* fff = subghz_txrx_get_fff_data(subghz->txrx);
|
||||
bool is_psa_encrypted = false;
|
||||
bool has_signal_editor = false;
|
||||
bool has_counter = false;
|
||||
if(fff) {
|
||||
FuriString* proto = furi_string_alloc();
|
||||
flipper_format_rewind(fff);
|
||||
if(flipper_format_read_string(fff, "Protocol", proto)) {
|
||||
has_signal_editor = !furi_string_equal_str(proto, "RAW");
|
||||
if(furi_string_equal_str(proto, "PSA GROUP")) {
|
||||
FuriString* type_str = furi_string_alloc();
|
||||
flipper_format_rewind(fff);
|
||||
if(!flipper_format_read_string(fff, "Type", type_str) ||
|
||||
furi_string_equal_str(type_str, "00")) {
|
||||
is_psa_encrypted = true;
|
||||
has_signal_editor = false;
|
||||
}
|
||||
furi_string_free(type_str);
|
||||
}
|
||||
@@ -53,6 +56,15 @@ void subghz_scene_saved_menu_on_enter(void* context) {
|
||||
SubmenuIndexEmulate,
|
||||
subghz_scene_saved_menu_submenu_callback,
|
||||
subghz);
|
||||
|
||||
if(has_signal_editor) {
|
||||
submenu_add_item(
|
||||
subghz->submenu,
|
||||
"Signal Editor",
|
||||
SubmenuIndexSignalSettings,
|
||||
subghz_scene_saved_menu_submenu_callback,
|
||||
subghz);
|
||||
}
|
||||
}
|
||||
|
||||
if(is_psa_encrypted) {
|
||||
@@ -85,15 +97,6 @@ void subghz_scene_saved_menu_on_enter(void* context) {
|
||||
subghz_scene_saved_menu_submenu_callback,
|
||||
subghz);
|
||||
|
||||
if(furi_hal_rtc_is_flag_set(FuriHalRtcFlagDebug)) {
|
||||
submenu_add_item(
|
||||
subghz->submenu,
|
||||
"Signal Settings",
|
||||
SubmenuIndexSignalSettings,
|
||||
subghz_scene_saved_menu_submenu_callback,
|
||||
subghz);
|
||||
}
|
||||
|
||||
if(has_counter) {
|
||||
submenu_add_item(
|
||||
subghz->submenu,
|
||||
@@ -118,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,10 +1,14 @@
|
||||
#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>
|
||||
#include <toolbox/strint.h>
|
||||
#include <lib/subghz/blocks/generic.h>
|
||||
#include <lib/subghz/blocks/custom_btn_i.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
|
||||
#define TAG "SubGhzSceneSignalSettings"
|
||||
|
||||
@@ -13,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;
|
||||
|
||||
@@ -21,6 +27,26 @@ static uint8_t btn_byte_count = 1;
|
||||
static uint8_t* btn_byte_ptr = NULL;
|
||||
|
||||
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] = {
|
||||
@@ -45,6 +71,30 @@ static const int32_t counter_mode_value[COUNTER_MODE_COUNT] = {
|
||||
7,
|
||||
};
|
||||
|
||||
static const uint8_t button_value[] = {
|
||||
SUBGHZ_CUSTOM_BTN_OK,
|
||||
SUBGHZ_CUSTOM_BTN_UP,
|
||||
SUBGHZ_CUSTOM_BTN_DOWN,
|
||||
SUBGHZ_CUSTOM_BTN_LEFT,
|
||||
SUBGHZ_CUSTOM_BTN_RIGHT,
|
||||
5,
|
||||
6,
|
||||
7,
|
||||
};
|
||||
|
||||
#define BUTTON_VALUE_COUNT SUBGHZ_BUTTON_LABEL_COUNT
|
||||
|
||||
static const char* button_labels[BUTTON_VALUE_COUNT] = {
|
||||
"Original",
|
||||
"Up",
|
||||
"Down",
|
||||
"Left",
|
||||
"Right",
|
||||
"Button 5",
|
||||
"Button 6",
|
||||
"Button 7",
|
||||
};
|
||||
|
||||
typedef struct {
|
||||
char* name;
|
||||
uint8_t mode_count;
|
||||
@@ -59,7 +109,206 @@ static Protocols protocols[] = {
|
||||
{"Phoenix_V2", 3},
|
||||
};
|
||||
|
||||
#define PROTOCOLS_COUNT (sizeof(protocols) / sizeof(Protocols));
|
||||
#define PROTOCOLS_COUNT (sizeof(protocols) / sizeof(Protocols))
|
||||
|
||||
static void subghz_scene_signal_settings_reset_button_labels(void) {
|
||||
subghz_button_labels_reset(button_labels);
|
||||
}
|
||||
|
||||
static void subghz_scene_signal_settings_apply_button_labels(const char* protocol) {
|
||||
subghz_button_labels_apply_protocol(protocol, button_labels);
|
||||
}
|
||||
|
||||
static const char* subghz_scene_signal_settings_get_button_label(uint8_t custom_btn_id) {
|
||||
return subghz_button_labels_get(
|
||||
button_labels, custom_btn_id, subghz_custom_btn_get_original());
|
||||
}
|
||||
|
||||
static bool subghz_scene_signal_settings_update_uint32_field(
|
||||
FlipperFormat* fff,
|
||||
const char* key,
|
||||
uint32_t value) {
|
||||
flipper_format_rewind(fff);
|
||||
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';
|
||||
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 subghz_scene_signal_settings_parse_hex_field(
|
||||
const char* text,
|
||||
const char* marker,
|
||||
uint32_t* value,
|
||||
uint8_t* length_bit) {
|
||||
const char* field = strstr(text, marker);
|
||||
if(!field) return false;
|
||||
|
||||
field += strlen(marker);
|
||||
while(*field == ' ' || *field == '\t') {
|
||||
field++;
|
||||
}
|
||||
|
||||
uint32_t parsed = 0;
|
||||
uint8_t digits = 0;
|
||||
uint8_t digit_value = 0;
|
||||
while(digits < 8 && subghz_scene_signal_settings_hex_digit(*field, &digit_value)) {
|
||||
parsed = (parsed << 4) | digit_value;
|
||||
digits++;
|
||||
field++;
|
||||
}
|
||||
|
||||
if(digits == 0) return false;
|
||||
|
||||
*value = parsed;
|
||||
*length_bit = digits * 4;
|
||||
return true;
|
||||
}
|
||||
|
||||
static void subghz_scene_signal_settings_apply_text_fallback(FuriString* decoded_text) {
|
||||
uint32_t value = 0;
|
||||
uint8_t length_bit = 0;
|
||||
const char* text = furi_string_get_cstr(decoded_text);
|
||||
|
||||
if(!subghz_block_generic_global.cnt_is_available &&
|
||||
subghz_scene_signal_settings_parse_hex_field(text, "Cnt:", &value, &length_bit)) {
|
||||
subghz_block_generic_global.cnt_is_available = true;
|
||||
subghz_block_generic_global.current_cnt = value;
|
||||
subghz_block_generic_global.cnt_length_bit = length_bit;
|
||||
}
|
||||
|
||||
if(!subghz_block_generic_global.btn_is_available &&
|
||||
subghz_scene_signal_settings_parse_hex_field(text, "Btn:", &value, &length_bit)) {
|
||||
subghz_block_generic_global.btn_is_available = true;
|
||||
subghz_block_generic_global.current_btn = value & 0xFF;
|
||||
subghz_block_generic_global.btn_length_bit = length_bit > 8 ? 8 : length_bit;
|
||||
}
|
||||
}
|
||||
|
||||
static void subghz_scene_signal_settings_apply_file_fallback(FlipperFormat* fff) {
|
||||
uint32_t value = 0;
|
||||
|
||||
if(!subghz_block_generic_global.cnt_is_available) {
|
||||
flipper_format_rewind(fff);
|
||||
if(flipper_format_read_uint32(fff, "Cnt", &value, 1)) {
|
||||
subghz_block_generic_global.cnt_is_available = true;
|
||||
subghz_block_generic_global.current_cnt = value;
|
||||
subghz_block_generic_global.cnt_length_bit = 32;
|
||||
}
|
||||
}
|
||||
|
||||
if(!subghz_block_generic_global.btn_is_available) {
|
||||
flipper_format_rewind(fff);
|
||||
if(flipper_format_read_uint32(fff, "Btn", &value, 1)) {
|
||||
subghz_block_generic_global.btn_is_available = true;
|
||||
subghz_block_generic_global.current_btn = value & 0xFF;
|
||||
subghz_block_generic_global.btn_length_bit = 8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static bool subghz_scene_signal_settings_rebuild_save_reload(
|
||||
SubGhz* subghz,
|
||||
bool use_custom_btn,
|
||||
uint8_t custom_btn_id) {
|
||||
const char* file_path = furi_string_get_cstr(subghz->file_path);
|
||||
FlipperFormat* fff = subghz_txrx_get_fff_data(subghz->txrx);
|
||||
|
||||
bool updated = false;
|
||||
int32_t counter_mult = furi_hal_subghz_get_rolling_counter_mult();
|
||||
furi_hal_subghz_set_rolling_counter_mult(0);
|
||||
|
||||
if(use_custom_btn) {
|
||||
subghz_custom_btn_set(custom_btn_id);
|
||||
}
|
||||
|
||||
do {
|
||||
if(!subghz_txrx_rebuild_from_fff(subghz->txrx, fff)) {
|
||||
FURI_LOG_E(TAG, "Error rebuilding protocol data");
|
||||
break;
|
||||
}
|
||||
if(!subghz_save_protocol_to_file(subghz, fff, file_path)) {
|
||||
FURI_LOG_E(TAG, "Error saving edited signal");
|
||||
break;
|
||||
}
|
||||
if(!subghz_key_load(subghz, file_path, false)) {
|
||||
FURI_LOG_E(TAG, "Error reloading edited signal");
|
||||
break;
|
||||
}
|
||||
updated = true;
|
||||
} while(false);
|
||||
|
||||
furi_hal_subghz_set_rolling_counter_mult(counter_mult);
|
||||
|
||||
if(!updated) {
|
||||
dialog_message_show_storage_error(subghz->dialogs, "Cannot save\nsignal");
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
void subghz_scene_signal_settings_counter_mode_changed(VariableItem* item) {
|
||||
uint8_t index = variable_item_get_current_value_index(item);
|
||||
@@ -99,6 +348,44 @@ 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;
|
||||
|
||||
button_custom_id = button_value[index];
|
||||
variable_item_set_current_value_text(
|
||||
item, subghz_scene_signal_settings_get_button_label(button_custom_id));
|
||||
|
||||
if(!button_uses_custom_btn) return;
|
||||
|
||||
SubGhz* subghz = variable_item_get_context(item);
|
||||
furi_assert(subghz);
|
||||
|
||||
subghz_scene_signal_settings_rebuild_save_reload(subghz, true, button_custom_id);
|
||||
}
|
||||
|
||||
void subghz_scene_signal_settings_byte_input_callback(void* context) {
|
||||
SubGhz* subghz = context;
|
||||
view_dispatcher_send_custom_event(subghz->view_dispatcher, SubGhzCustomEventByteInputDone);
|
||||
@@ -108,8 +395,10 @@ void subghz_scene_signal_settings_variable_item_list_enter_callback(void* contex
|
||||
SubGhz* subghz = context;
|
||||
|
||||
// when we click OK on "Edit counter" item
|
||||
if(index == 1) {
|
||||
if(index == SignalSettingsIndexCounter) {
|
||||
if(!cnt_byte_ptr || cnt_byte_count == 0) return;
|
||||
submenu_called = 1;
|
||||
furi_string_set_str(byte_input_text, "Enter ");
|
||||
furi_string_cat_printf(byte_input_text, "%i", subghz_block_generic_global.cnt_length_bit);
|
||||
furi_string_cat_str(byte_input_text, "-bits counter in HEX");
|
||||
|
||||
@@ -127,8 +416,10 @@ void subghz_scene_signal_settings_variable_item_list_enter_callback(void* contex
|
||||
view_dispatcher_switch_to_view(subghz->view_dispatcher, SubGhzViewIdByteInput);
|
||||
}
|
||||
// when we click OK on "Edit button" item
|
||||
if(index == 2) {
|
||||
if(index == SignalSettingsIndexButton && !button_uses_custom_btn) {
|
||||
if(!btn_byte_ptr || btn_byte_count == 0) return;
|
||||
submenu_called = 2;
|
||||
furi_string_set_str(byte_input_text, "Enter ");
|
||||
furi_string_cat_printf(byte_input_text, "%i", subghz_block_generic_global.btn_length_bit);
|
||||
furi_string_cat_str(byte_input_text, "-bits button in HEX");
|
||||
|
||||
@@ -150,6 +441,22 @@ void subghz_scene_signal_settings_variable_item_list_enter_callback(void* contex
|
||||
void subghz_scene_signal_settings_on_enter(void* context) {
|
||||
SubGhz* subghz = context;
|
||||
|
||||
counter32 = 0;
|
||||
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;
|
||||
submenu_called = 0;
|
||||
button_uses_custom_btn = false;
|
||||
button_custom_id = SUBGHZ_CUSTOM_BTN_OK;
|
||||
subghz_block_generic_global_reset(NULL);
|
||||
subghz_custom_btns_reset();
|
||||
subghz_scene_signal_settings_reset_button_labels();
|
||||
|
||||
// ### Counter mode section ###
|
||||
|
||||
// When we open saved file we do some check and fill up subghz->file_path.
|
||||
@@ -162,6 +469,7 @@ void subghz_scene_signal_settings_on_enter(void* context) {
|
||||
Storage* storage = furi_record_open(RECORD_STORAGE);
|
||||
FlipperFormat* fff_data_file = flipper_format_file_alloc(storage);
|
||||
FuriString* tmp_text = furi_string_alloc_set_str("");
|
||||
FuriString* protocol_name = furi_string_alloc();
|
||||
|
||||
uint32_t tmp_counter_mode = 0;
|
||||
counter_mode = 0xff;
|
||||
@@ -174,8 +482,9 @@ void subghz_scene_signal_settings_on_enter(void* context) {
|
||||
FURI_LOG_E(TAG, "Error open file %s", file_path);
|
||||
} else {
|
||||
flipper_format_read_string(fff_data_file, "Protocol", tmp_text);
|
||||
furi_string_set(protocol_name, tmp_text);
|
||||
// compare available protocols names, load CounterMode value from file and setup variable_item_list values_count
|
||||
for(uint8_t i = 0; i < PROTOCOLS_COUNT i++) {
|
||||
for(uint8_t i = 0; i < PROTOCOLS_COUNT; i++) {
|
||||
if(!strcmp(furi_string_get_cstr(tmp_text), protocols[i].name)) {
|
||||
mode_count = protocols[i].mode_count;
|
||||
if(flipper_format_read_uint32(fff_data_file, "CounterMode", &tmp_counter_mode, 1)) {
|
||||
@@ -223,7 +532,10 @@ void subghz_scene_signal_settings_on_enter(void* context) {
|
||||
// deserialaze and decode loaded sugbhz file and push data to subghz_block_generic_global variable
|
||||
if(subghz_protocol_decoder_base_deserialize(decoder, subghz_txrx_get_fff_data(subghz->txrx)) ==
|
||||
SubGhzProtocolStatusOk) {
|
||||
subghz_scene_signal_settings_apply_button_labels(furi_string_get_cstr(protocol_name));
|
||||
subghz_protocol_decoder_base_get_string(decoder, tmp_text);
|
||||
subghz_scene_signal_settings_apply_text_fallback(tmp_text);
|
||||
subghz_scene_signal_settings_apply_file_fallback(subghz_txrx_get_fff_data(subghz->txrx));
|
||||
} else {
|
||||
FURI_LOG_E(TAG, "Cant deserialize this subghz file");
|
||||
}
|
||||
@@ -232,55 +544,75 @@ void subghz_scene_signal_settings_on_enter(void* context) {
|
||||
|
||||
if(!subghz_block_generic_global.cnt_is_available) {
|
||||
counter_mode = 0xff;
|
||||
furi_string_set_str(tmp_text, "-");
|
||||
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 !");
|
||||
//
|
||||
|
||||
// ### Button edit section ###
|
||||
|
||||
if(!subghz_block_generic_global.btn_is_available) {
|
||||
if(subghz_custom_btn_is_allowed()) {
|
||||
uint8_t max_custom_btn = subghz_custom_btn_get_max();
|
||||
uint8_t custom_button_count = max_custom_btn + 1;
|
||||
if(custom_button_count > BUTTON_VALUE_COUNT) custom_button_count = BUTTON_VALUE_COUNT;
|
||||
button_uses_custom_btn = custom_button_count > 1;
|
||||
}
|
||||
|
||||
if(button_uses_custom_btn) {
|
||||
button_not_available = false;
|
||||
furi_string_set_str(
|
||||
tmp_text, subghz_scene_signal_settings_get_button_label(SUBGHZ_CUSTOM_BTN_OK));
|
||||
} else if(!subghz_block_generic_global.btn_is_available) {
|
||||
furi_string_set_str(tmp_text, "-");
|
||||
FURI_LOG_D(TAG, "Button edit not available for this protocol");
|
||||
} else {
|
||||
button_not_available = false;
|
||||
button = subghz_block_generic_global.current_btn;
|
||||
furi_string_printf(tmp_text, "%X", button);
|
||||
btn_byte_ptr = (uint8_t*)&button;
|
||||
furi_string_printf(tmp_text, "%X", button);
|
||||
}
|
||||
|
||||
item = variable_item_list_add(variable_item_list, "Edit Button", 1, NULL, subghz);
|
||||
uint8_t button_count = 1;
|
||||
if(button_uses_custom_btn) {
|
||||
button_count = subghz_custom_btn_get_max() + 1;
|
||||
if(button_count > BUTTON_VALUE_COUNT) button_count = BUTTON_VALUE_COUNT;
|
||||
}
|
||||
item = variable_item_list_add(
|
||||
variable_item_list,
|
||||
button_uses_custom_btn ? "Button" : "Edit Button",
|
||||
button_count,
|
||||
button_uses_custom_btn ? subghz_scene_signal_settings_button_changed : NULL,
|
||||
subghz);
|
||||
variable_item_set_current_value_index(item, 0);
|
||||
variable_item_set_current_value_text(item, furi_string_get_cstr(tmp_text));
|
||||
variable_item_set_locked(item, (button_not_available), "Not available\nfor this\nprotocol !");
|
||||
//
|
||||
|
||||
furi_assert(cnt_byte_ptr);
|
||||
furi_assert(cnt_byte_count > 0);
|
||||
furi_assert(btn_byte_ptr);
|
||||
|
||||
furi_string_free(tmp_text);
|
||||
furi_string_free(protocol_name);
|
||||
|
||||
view_dispatcher_switch_to_view(subghz->view_dispatcher, SubGhzViewIdVariableItemList);
|
||||
}
|
||||
@@ -290,23 +622,21 @@ bool subghz_scene_signal_settings_on_event(void* context, SceneManagerEvent even
|
||||
|
||||
if(event.type == SceneManagerEventTypeCustom) {
|
||||
if(event.event == SubGhzCustomEventByteInputDone) {
|
||||
FlipperFormat* fff = subghz_txrx_get_fff_data(subghz->txrx);
|
||||
|
||||
switch(submenu_called) {
|
||||
// edit counter
|
||||
case 1:
|
||||
switch(cnt_byte_count) {
|
||||
case 2:
|
||||
// set new cnt value and override_flag to global variable and call transmit to generate and save subghz signal
|
||||
counter16 = __bswap16(counter16);
|
||||
subghz_block_generic_global_counter_override_set(counter16);
|
||||
subghz_tx_start(subghz, subghz_txrx_get_fff_data(subghz->txrx));
|
||||
subghz_txrx_stop(subghz->txrx);
|
||||
subghz_scene_signal_settings_counter_set_value(counter16);
|
||||
subghz_scene_signal_settings_counter_save(subghz);
|
||||
break;
|
||||
case 4:
|
||||
// the same for 32 bit Counter
|
||||
counter32 = __bswap32(counter32);
|
||||
subghz_block_generic_global_counter_override_set(counter32);
|
||||
subghz_tx_start(subghz, subghz_txrx_get_fff_data(subghz->txrx));
|
||||
subghz_txrx_stop(subghz->txrx);
|
||||
subghz_scene_signal_settings_counter_set_value(counter32);
|
||||
subghz_scene_signal_settings_counter_save(subghz);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -314,14 +644,10 @@ bool subghz_scene_signal_settings_on_event(void* context, SceneManagerEvent even
|
||||
break;
|
||||
// edit button
|
||||
case 2:
|
||||
subghz_scene_signal_settings_update_uint32_field(fff, "Btn", button);
|
||||
subghz_block_generic_global_button_override_set(button);
|
||||
// save counter mult to rewrite subghz singnal without changing counter
|
||||
int32_t tmp_counter = furi_hal_subghz_get_rolling_counter_mult();
|
||||
furi_hal_subghz_set_rolling_counter_mult(0);
|
||||
subghz_tx_start(subghz, subghz_txrx_get_fff_data(subghz->txrx));
|
||||
subghz_txrx_stop(subghz->txrx);
|
||||
// restore counter mult
|
||||
furi_hal_subghz_set_rolling_counter_mult(tmp_counter);
|
||||
subghz_scene_signal_settings_rebuild_save_reload(
|
||||
subghz, false, SUBGHZ_CUSTOM_BTN_OK);
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -330,13 +656,10 @@ bool subghz_scene_signal_settings_on_event(void* context, SceneManagerEvent even
|
||||
|
||||
scene_manager_previous_scene(subghz->scene_manager);
|
||||
return true;
|
||||
|
||||
} else {
|
||||
if(event.type == SceneManagerEventTypeBack) {
|
||||
scene_manager_previous_scene(subghz->scene_manager);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else if(event.type == SceneManagerEventTypeBack) {
|
||||
scene_manager_previous_scene(subghz->scene_manager);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -352,4 +675,5 @@ void subghz_scene_signal_settings_on_exit(void* context) {
|
||||
byte_input_set_result_callback(subghz->byte_input, NULL, NULL, NULL, NULL, 0);
|
||||
byte_input_set_header_text(subghz->byte_input, "");
|
||||
furi_string_free(byte_input_text);
|
||||
subghz_custom_btns_reset();
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, ¤t, ¤t_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);
|
||||
}
|
||||
|
||||
@@ -50,7 +50,11 @@ Navigate with **Up/Down**, activate with **OK**, close with **Back**.
|
||||
| **Press START** | Sends a Start press to the game (pause menus, "PRESS START" screens) and resumes |
|
||||
| **Press SELECT** | Sends a Select press to the game and resumes |
|
||||
| **Frameskip** | Change with **Left/Right**: `auto` (recommended), or fixed `0–4`. `auto` shows the skip level currently in use |
|
||||
| **Sound** | Toggle piezo sound on/off (`n/a` if the speaker is in use by another app) |
|
||||
| **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 |
|
||||
|
||||
@@ -95,8 +99,9 @@ piezo:
|
||||
3. otherwise the noise channel, mapped to a short low buzz (percussion).
|
||||
|
||||
The result is a monophonic ringtone-style rendition of the game's music
|
||||
and sound effects (sweeps like Mario's jump work). It can be toggled in
|
||||
the emulator menu. Since no waveforms are synthesized, the CPU cost is
|
||||
and sound effects (sweeps like Mario's jump work). Volume is adjustable in
|
||||
the emulator menu (off/25/50/75/100%, perceptually spaced for the piezo's
|
||||
nonlinear loudness). Since no waveforms are synthesized, the CPU cost is
|
||||
negligible (one counter per emulated instruction) and RAM cost is ~120
|
||||
bytes.
|
||||
|
||||
@@ -108,8 +113,31 @@ bytes.
|
||||
(70224 M-cycles instead of 17556). Unnoticeable on a desktop, a slideshow
|
||||
on a 64 MHz Cortex-M4. Fixed — this alone made everything ~4.5x faster.
|
||||
- While halted (games spend most of each frame in HALT waiting for vblank),
|
||||
the CPU steps 4 M-cycles at a time instead of 1, making the idle part of
|
||||
the CPU steps 8 M-cycles at a time instead of 1, making the idle part of
|
||||
the frame cheap.
|
||||
- PPU/timer/APU use catch-up batching: instead of stepping their state
|
||||
machines after every CPU instruction, cycles accumulate and are flushed
|
||||
every 32 M-cycles - or immediately before any IO register access, so
|
||||
LY/STAT/DIV/TIMA/IF always read exact. This removes the single biggest
|
||||
per-instruction cost of the interpreter loop (~2.8x faster emulation).
|
||||
- Undefined opcodes cost 1 cycle instead of 0: with 0-cycle entries,
|
||||
executing garbage could advance the PC without advancing the PPU clock
|
||||
and spin run_to_vblank() forever, freezing the whole device.
|
||||
- EI enables interrupts only after the following instruction (hardware
|
||||
behaviour). Without the delay, the classic `EI / HALT` pause idiom
|
||||
consumed its wake interrupt before HALT and games froze waiting for a
|
||||
button press that had already been eaten (input dead, music playing).
|
||||
- STOP freezes the CPU until joypad activity, independent of IE/IF/IME
|
||||
(hardware behaviour), and skips its padding byte.
|
||||
- ROM streaming: bank switches to the already-mapped bank skip the cache;
|
||||
the upper 8 KB half of a bank streams in lazily on first read; the LRU
|
||||
never evicts the most-recently-used page and protects the single hottest
|
||||
page (the music-driver bank) from map-streaming evictions; the Gameboy
|
||||
core block is pre-allocated during ROM load so the page cache can size
|
||||
itself against real free heap (Pokemon: ~2x more cache slots).
|
||||
- The auto-frameskip EMA uses signed arithmetic (an unsigned underflow
|
||||
could pin it at maximum), and the menu shows the measured cost of one
|
||||
emulated frame in ms next to the free heap (16.7ms = full speed).
|
||||
- **Frameskip `auto`** (default) measures the real cost of each emulated frame
|
||||
and skips *rendering* (never emulation) to keep the game running at correct
|
||||
speed. Games stay full-speed logically; visible FPS drops instead.
|
||||
@@ -117,8 +145,32 @@ 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.
|
||||
- Bank-switch heavy games may micro-stutter when a 16 KB bank has to be
|
||||
streamed from the SD card (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.
|
||||
- The CPU interpreter is a unity build: the 500 opcode bodies compile in
|
||||
the same translation unit as the dispatch switch, so the compiler inlines
|
||||
them directly into the jump table (two cross-TU calls per instruction
|
||||
eliminated) - and the deduplication actually made the binary smaller.
|
||||
- The PPU renders tiles byte-wise, not pixel-wise: tile bitplanes are
|
||||
decoded 8 pixels at once through a 256-entry spread LUT and written as
|
||||
whole palette-mapped bytes (4 px per store) - ~7x fewer operations per
|
||||
scanline. Sprites skip fully-transparent rows and use the same LUTs.
|
||||
- The 160x144 -> 128x64 downscale/dither also works one packed byte
|
||||
(4 pixels) per lookup instead of per-pixel shade extraction.
|
||||
- In `auto` mode the frameskip may go up to 8 (fixed settings stay 0-4):
|
||||
for heavy streamed games, correct game speed at a lower visible fps beats
|
||||
slow motion.
|
||||
- The emulator menu shows the free heap (`NNk free`) so you can see the
|
||||
memory headroom of the current game at a glance.
|
||||
|
||||
@@ -149,8 +201,8 @@ ufbt launch # builds, installs and runs on a connected Flipper
|
||||
The exact core that ships in the FAP can be compiled and tested on a desktop:
|
||||
|
||||
```sh
|
||||
g++ -std=c++17 -O2 -fno-exceptions -fno-rtti -I gb \
|
||||
-o hosttest/hosttest hosttest/main.cpp gb/*.cc
|
||||
g++ -std=c++17 -O2 -fno-exceptions -fno-rtti -I lib/gbcore \
|
||||
-o hosttest/hosttest hosttest/main.cpp lib/gbcore/*.cc
|
||||
|
||||
# Blargg CPU tests (print Passed/Failed via the serial port):
|
||||
./hosttest/hosttest path/to/01-special.gb 4000
|
||||
@@ -170,8 +222,8 @@ Current status: **Blargg `cpu_instrs` 11/11 PASS**.
|
||||
|
||||
| Upstream (PC) | This port (Flipper) |
|
||||
|---|---|
|
||||
| Whole ROM in RAM (with several transient copies) | 16 KB bank streaming from SD with an adaptive LRU cache; bank 0 resident; when every bank fits, the whole ROM is preloaded into individual 16 KB slots (O(1) switching, SD file closed) |
|
||||
| — | All ROM-dependent allocations are 16 KB or smaller and are checked against the largest free heap block first: heap fragmentation can never crash the firmware, the app degrades to streaming or shows "Not enough RAM" instead |
|
||||
| Whole ROM in RAM (with several transient copies) | 8 KB page streaming from SD with an adaptive LRU cache; bank 0 resident; when every page fits, the whole ROM is preloaded into individual 8 KB slots (O(1) switching, SD file closed) |
|
||||
| — | All ROM-dependent allocations are 8 KB or smaller and are checked against the largest free heap block first: heap fragmentation can never crash the firmware, the app degrades to streaming or shows "Not enough RAM" instead |
|
||||
| Renders all 144 scanlines | Renders only the 64 scanlines that are actually displayed after the 144→64 downscale (row mask, ~2x faster rendering) |
|
||||
| PPU counts M-cycles against T-cycle constants (4x too much CPU emulation per frame) | Hardware-correct M-cycle constants (114 per scanline): ~4.5x faster overall |
|
||||
| DIV register incremented every M-cycle (64x too fast) | Correct 16384 Hz rate (games use DIV for delays and randomness) |
|
||||
@@ -187,14 +239,14 @@ Current status: **Blargg `cpu_instrs` 11/11 PASS**.
|
||||
| No APU at all | Register-level APU (sweep/envelope/length/NR52, proper read-back masks) driving the piezo with the dominant voice |
|
||||
|
||||
RAM budget on device (256 KB total, ~140 KB heap; the app binary itself
|
||||
loads into ~32 KB of that heap): ~19.5 KB emulation state, 16 KB bank 0,
|
||||
adaptive bank cache (10 KB heap kept in reserve for the system), 0–32 KB
|
||||
cartridge RAM per game, 4 KB stack. The bank cache is allocated greedily in
|
||||
independent 16 KB blocks until the reserve would be touched, so any `.gb`
|
||||
loads into ~37 KB of that heap): ~19.5 KB emulation state, 16 KB bank 0,
|
||||
adaptive page cache (10 KB heap kept in reserve for the system), 0–32 KB
|
||||
cartridge RAM per game, 4 KB stack. The page cache is allocated greedily in
|
||||
independent 8 KB blocks until the reserve would be touched, so any `.gb`
|
||||
ROM size works: small ROMs end up fully resident, large ones stream through
|
||||
however many slots fit. Worst case (1 MB ROM + 32 KB battery RAM, e.g.
|
||||
Pokémon Red/Blue) needs ~78 KB before the first cache slot, which fits the
|
||||
post-launch heap with room for 1–2 streaming slots.
|
||||
post-launch heap with room for a few streaming pages.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -3,13 +3,24 @@ App(
|
||||
name="FlipGB",
|
||||
apptype=FlipperAppType.EXTERNAL,
|
||||
entry_point="flipgb_app",
|
||||
sources=["*.c*", "!hosttest"],
|
||||
sources=["*.c*", "!hosttest", "!lib", "!tests"],
|
||||
requires=["gui", "dialogs", "storage"],
|
||||
stack_size=8 * 1024,
|
||||
cdefines=[("GB_FB_ROWS", "64")],
|
||||
fap_private_libs=[
|
||||
Lib(
|
||||
# The emulator core is built as a private lib so it can use -O2:
|
||||
# ufbt builds FAP sources with -Os, which costs ~30% emulation
|
||||
# speed in the per-instruction hot path. Code size difference
|
||||
# is ~6 KB - well worth it.
|
||||
name="gbcore",
|
||||
cflags=["-O2"],
|
||||
cdefines=[("GB_FB_ROWS", "64")],
|
||||
),
|
||||
],
|
||||
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.",
|
||||
)
|
||||
|
||||
Binary file not shown.
BIN
Binary file not shown.
@@ -1,80 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "definitions.h"
|
||||
|
||||
/* Cartridge with pluggable ROM bank provider.
|
||||
*
|
||||
* Instead of holding the whole ROM in RAM (impossible on Flipper Zero for
|
||||
* anything above 32 KB), the cartridge asks the platform for a pointer to a
|
||||
* 16 KB bank whenever the game switches banks. On the desktop test build the
|
||||
* provider just returns `rom + bank * 0x4000`; on the Flipper it is backed
|
||||
* by an LRU cache streaming from the SD card.
|
||||
*
|
||||
* Supported mappers: ROM only, MBC1 (incl. upper bits / mode select),
|
||||
* MBC2 (built-in 512x4 RAM), MBC3 (no RTC), MBC5.
|
||||
*/
|
||||
|
||||
using RomBankProvider = const u8* (*)(void* ctx, uint bank);
|
||||
|
||||
enum class MBCType : u8 {
|
||||
None,
|
||||
MBC1,
|
||||
MBC2,
|
||||
MBC3,
|
||||
MBC5,
|
||||
Unsupported,
|
||||
};
|
||||
|
||||
class Cartridge {
|
||||
public:
|
||||
/* bank0 must stay valid for the lifetime of the cartridge */
|
||||
void init(
|
||||
const u8* bank0_data,
|
||||
uint rom_bank_count,
|
||||
MBCType mbc_type,
|
||||
u8* cart_ram,
|
||||
u32 cart_ram_size,
|
||||
RomBankProvider provider,
|
||||
void* provider_ctx);
|
||||
|
||||
auto read(u16 addr) const -> u8 {
|
||||
if(addr < 0x4000) return bank0[addr];
|
||||
if(addr < 0x8000) return bankN[addr - 0x4000];
|
||||
/* 0xA000 - 0xBFFF: cartridge RAM */
|
||||
return read_ram(addr);
|
||||
}
|
||||
|
||||
void write(u16 addr, u8 value);
|
||||
|
||||
auto get_ram() -> u8* { return ram; }
|
||||
auto get_ram_size() const -> u32 { return ram_size; }
|
||||
|
||||
/* 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;
|
||||
static auto rom_bank_count_from_header(u8 rom_size_byte) -> uint;
|
||||
static auto ram_size_from_header(u8 ram_size_byte, MBCType mbc) -> u32;
|
||||
|
||||
private:
|
||||
auto read_ram(u16 addr) const -> u8;
|
||||
void write_ram(u16 addr, u8 value);
|
||||
void update_rom_bank();
|
||||
|
||||
const u8* bank0 = nullptr;
|
||||
const u8* bankN = nullptr;
|
||||
|
||||
u8* ram = nullptr;
|
||||
u32 ram_size = 0;
|
||||
|
||||
RomBankProvider provider = nullptr;
|
||||
void* provider_ctx = nullptr;
|
||||
|
||||
MBCType mbc = MBCType::None;
|
||||
uint bank_count = 2;
|
||||
|
||||
bool ram_enabled = false;
|
||||
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 */
|
||||
uint ram_bank = 0;
|
||||
};
|
||||
@@ -1,73 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "input.h"
|
||||
#include "cpu.h"
|
||||
#include "video.h"
|
||||
#include "timer.h"
|
||||
#include "mmu.h"
|
||||
#include "cartridge.h"
|
||||
#include "apu.h"
|
||||
|
||||
class Gameboy {
|
||||
public:
|
||||
/* bank0: pointer to the first 16 KB of ROM (stays resident).
|
||||
* provider: returns pointers to 16 KB switchable banks. */
|
||||
Gameboy(
|
||||
const u8* bank0,
|
||||
uint rom_bank_count,
|
||||
MBCType mbc,
|
||||
u8* cart_ram,
|
||||
u32 cart_ram_size,
|
||||
RomBankProvider provider,
|
||||
void* provider_ctx);
|
||||
|
||||
/* Runs the emulator until the next vblank (one full frame). */
|
||||
void run_to_vblank();
|
||||
|
||||
/* Called on every vblank BEFORE the framebuffer is cleared for the next
|
||||
* frame: this is where the frontend must convert/copy the image. */
|
||||
void set_frame_callback(void (*cb)(void*), void* ctx) {
|
||||
user_frame_cb = cb;
|
||||
user_frame_ctx = ctx;
|
||||
}
|
||||
|
||||
void button_pressed(GbButton button);
|
||||
void button_released(GbButton button);
|
||||
|
||||
void set_skip_render(bool skip) { video.skip_render = skip; }
|
||||
|
||||
/* Optional display-line mask (see Video::row_mask). The array must stay
|
||||
* valid for the lifetime of the emulator. */
|
||||
void set_row_mask(const u8* mask) { video.set_row_mask(mask); }
|
||||
auto get_framebuffer() const -> const FrameBuffer& { return video.get_framebuffer(); }
|
||||
|
||||
auto get_cartridge_ram() -> u8* { return cartridge.get_ram(); }
|
||||
auto get_cartridge_ram_size() const -> u32 { return cartridge.get_ram_size(); }
|
||||
|
||||
Cartridge cartridge;
|
||||
|
||||
CPU cpu;
|
||||
friend class CPU;
|
||||
|
||||
Video video;
|
||||
friend class Video;
|
||||
|
||||
MMU mmu;
|
||||
friend class MMU;
|
||||
|
||||
Timer timer;
|
||||
friend class Timer;
|
||||
|
||||
Apu apu;
|
||||
|
||||
Input input;
|
||||
|
||||
private:
|
||||
void tick();
|
||||
|
||||
static void vblank_trampoline(void* ctx);
|
||||
volatile bool frame_done = false;
|
||||
|
||||
void (*user_frame_cb)(void*) = nullptr;
|
||||
void* user_frame_ctx = nullptr;
|
||||
};
|
||||
@@ -1,54 +0,0 @@
|
||||
#include "input.h"
|
||||
|
||||
#include "bitwise.h"
|
||||
|
||||
void Input::button_pressed(GbButton button) {
|
||||
set_button(button, true);
|
||||
}
|
||||
|
||||
void Input::button_released(GbButton button) {
|
||||
set_button(button, false);
|
||||
}
|
||||
|
||||
void Input::set_button(GbButton button, bool set) {
|
||||
if (button == GbButton::Up) { up = set; }
|
||||
if (button == GbButton::Down) { down = set; }
|
||||
if (button == GbButton::Left) { left = set; }
|
||||
if (button == GbButton::Right) { right = set; }
|
||||
if (button == GbButton::A) { a = set; }
|
||||
if (button == GbButton::B) { b = set; }
|
||||
if (button == GbButton::Select) { select = set; }
|
||||
if (button == GbButton::Start) { start = set; }
|
||||
}
|
||||
|
||||
void Input::write(u8 set) {
|
||||
using bitwise::check_bit;
|
||||
|
||||
direction_switch = !check_bit(set, 4);
|
||||
button_switch = !check_bit(set, 5);
|
||||
}
|
||||
|
||||
auto Input::get_input() const -> u8 {
|
||||
using bitwise::set_bit_to;
|
||||
|
||||
u8 buttons = 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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
buttons = set_bit_to(buttons, 4, !direction_switch);
|
||||
buttons = set_bit_to(buttons, 5, !button_switch);
|
||||
|
||||
return buttons;
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "address.h"
|
||||
#include "definitions.h"
|
||||
|
||||
class Gameboy;
|
||||
|
||||
/* Serial output hook, used by the host test harness to capture Blargg test
|
||||
* ROM output. Null on the Flipper build. */
|
||||
extern "C" {
|
||||
extern void (*gb_serial_hook)(u8 byte);
|
||||
}
|
||||
|
||||
class MMU {
|
||||
public:
|
||||
MMU(Gameboy& inGb);
|
||||
|
||||
auto read(const Address& address) const -> u8;
|
||||
void write(const Address& address, u8 byte);
|
||||
|
||||
private:
|
||||
auto read_io(const Address& address) const -> u8;
|
||||
void write_io(const Address& address, u8 byte);
|
||||
void dma_transfer(u8 byte);
|
||||
|
||||
Gameboy& gb;
|
||||
|
||||
u8 work_ram[0x2000] = {}; /* DMG: 8 KB (was 32 KB upstream) */
|
||||
u8 oam_ram[0xA0] = {};
|
||||
u8 high_ram[0x80] = {};
|
||||
|
||||
u8 serial_data = 0;
|
||||
|
||||
friend class Video;
|
||||
};
|
||||
@@ -1,77 +0,0 @@
|
||||
#include "timer.h"
|
||||
|
||||
#include "definitions.h"
|
||||
#include "gameboy.h"
|
||||
#include "cpu.h"
|
||||
#include "bitwise.h"
|
||||
|
||||
const uint CLOCKS_PER_CYCLE = 4;
|
||||
|
||||
Timer::Timer(Gameboy& _gb) : gb(_gb) {}
|
||||
|
||||
void Timer::tick(uint cycles) {
|
||||
/* DIV increments at 16384 Hz = every 64 M-cycles (upstream incremented
|
||||
* it once per M-cycle: 64x too fast, breaking games that use DIV for
|
||||
* delays or randomness) */
|
||||
div_clocks += cycles;
|
||||
if(div_clocks >= 64) {
|
||||
divider.set(static_cast<u8>(divider.value() + (div_clocks >> 6)));
|
||||
div_clocks &= 63;
|
||||
}
|
||||
|
||||
clocks += cycles * CLOCKS_PER_CYCLE;
|
||||
|
||||
auto timer_is_on = timer_control.check_bit(2);
|
||||
if (timer_is_on == 0) { return; }
|
||||
|
||||
auto clock_limit = clocks_needed_to_increment();
|
||||
|
||||
if (clocks >= clock_limit) {
|
||||
clocks = clocks % clock_limit;
|
||||
|
||||
u8 old_timer_counter = timer_counter.value();
|
||||
timer_counter.increment();
|
||||
|
||||
if (timer_counter.value() < old_timer_counter) {
|
||||
gb.cpu.interrupt_flag.set_bit_to(2, true);
|
||||
timer_counter.set(timer_modulo.value());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auto Timer::get_divider() const -> u8 { return divider.value(); }
|
||||
|
||||
auto Timer::get_timer() const -> u8 { return timer_counter.value(); }
|
||||
|
||||
auto Timer::get_timer_modulo() const -> u8 { return timer_modulo.value(); }
|
||||
|
||||
// Only the bottom three bits of this register are usable
|
||||
auto Timer::get_timer_control() const -> u8 { return timer_control.value() & 0x3; }
|
||||
|
||||
void Timer::reset_divider() {
|
||||
divider.set(0x0);
|
||||
}
|
||||
|
||||
void Timer::set_timer(u8 value) {
|
||||
timer_counter.set(value);
|
||||
}
|
||||
|
||||
void Timer::set_timer_modulo(u8 value) {
|
||||
timer_modulo.set(value);
|
||||
}
|
||||
|
||||
void Timer::set_timer_control(u8 value) {
|
||||
timer_control.set(value);
|
||||
}
|
||||
|
||||
uint Timer::clocks_needed_to_increment() {
|
||||
using bitwise::check_bit;
|
||||
|
||||
switch (get_timer_control()) {
|
||||
case 0: return CLOCK_RATE / 4096;
|
||||
case 1: return CLOCK_RATE / 262144;
|
||||
case 2: return CLOCK_RATE / 65536;
|
||||
case 3: return CLOCK_RATE / 16384;
|
||||
default: return CLOCK_RATE / 4096; /* unreachable */
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "definitions.h"
|
||||
#include "register.h"
|
||||
|
||||
class Gameboy;
|
||||
|
||||
class Timer {
|
||||
public:
|
||||
Timer(Gameboy& inGb);
|
||||
|
||||
void tick(uint cycles);
|
||||
|
||||
auto get_divider() const -> u8;
|
||||
auto get_timer() const -> u8;
|
||||
auto get_timer_modulo() const -> u8;
|
||||
auto get_timer_control() const -> u8;
|
||||
|
||||
void reset_divider();
|
||||
void set_timer(u8 value);
|
||||
void set_timer_modulo(u8 value);
|
||||
void set_timer_control(u8 value);
|
||||
|
||||
private:
|
||||
uint clocks_needed_to_increment();
|
||||
|
||||
uint clocks = 0;
|
||||
uint div_clocks = 0;
|
||||
|
||||
Gameboy& gb;
|
||||
|
||||
ByteRegister divider;
|
||||
ByteRegister timer_counter;
|
||||
|
||||
ByteRegister timer_modulo;
|
||||
ByteRegister timer_control;
|
||||
};
|
||||
@@ -1,353 +0,0 @@
|
||||
#include "video.h"
|
||||
|
||||
#include "gameboy.h"
|
||||
#include "cpu.h"
|
||||
#include "bitwise.h"
|
||||
|
||||
using bitwise::check_bit;
|
||||
|
||||
Video::Video(Gameboy& inGb)
|
||||
: gb(inGb) {
|
||||
}
|
||||
|
||||
void Video::tick(Cycles cycles) {
|
||||
cycle_counter += cycles.cycles;
|
||||
|
||||
switch(current_mode) {
|
||||
case VideoMode::ACCESS_OAM:
|
||||
if(cycle_counter >= CLOCKS_PER_SCANLINE_OAM) {
|
||||
cycle_counter = cycle_counter % CLOCKS_PER_SCANLINE_OAM;
|
||||
lcd_status.set_bit_to(1, true);
|
||||
lcd_status.set_bit_to(0, true);
|
||||
current_mode = VideoMode::ACCESS_VRAM;
|
||||
}
|
||||
break;
|
||||
case VideoMode::ACCESS_VRAM:
|
||||
if(cycle_counter >= CLOCKS_PER_SCANLINE_VRAM) {
|
||||
cycle_counter = cycle_counter % CLOCKS_PER_SCANLINE_VRAM;
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
break;
|
||||
case VideoMode::HBLANK:
|
||||
if(cycle_counter >= CLOCKS_PER_HBLANK) {
|
||||
if(!skip_render) write_scanline(line.value());
|
||||
line.increment();
|
||||
|
||||
cycle_counter = cycle_counter % CLOCKS_PER_HBLANK;
|
||||
|
||||
/* Line 145 (index 144) is the first line of VBLANK */
|
||||
if(line == 144) {
|
||||
current_mode = VideoMode::VBLANK;
|
||||
lcd_status.set_bit_to(1, false);
|
||||
lcd_status.set_bit_to(0, true);
|
||||
gb.cpu.interrupt_flag.set_bit_to(0, true);
|
||||
} else {
|
||||
lcd_status.set_bit_to(1, true);
|
||||
lcd_status.set_bit_to(0, false);
|
||||
current_mode = VideoMode::ACCESS_OAM;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case VideoMode::VBLANK:
|
||||
if(cycle_counter >= CLOCKS_PER_SCANLINE) {
|
||||
line.increment();
|
||||
|
||||
cycle_counter = cycle_counter % CLOCKS_PER_SCANLINE;
|
||||
|
||||
/* 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();
|
||||
current_mode = VideoMode::ACCESS_OAM;
|
||||
lcd_status.set_bit_to(1, true);
|
||||
lcd_status.set_bit_to(0, false);
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
auto Video::display_enabled() const -> bool {
|
||||
return check_bit(control_byte, 7);
|
||||
}
|
||||
auto Video::window_tile_map() const -> bool {
|
||||
return check_bit(control_byte, 6);
|
||||
}
|
||||
auto Video::window_enabled() const -> bool {
|
||||
return check_bit(control_byte, 5);
|
||||
}
|
||||
auto Video::bg_window_tile_data() const -> bool {
|
||||
return check_bit(control_byte, 4);
|
||||
}
|
||||
auto Video::bg_tile_map_display() const -> bool {
|
||||
return check_bit(control_byte, 3);
|
||||
}
|
||||
auto Video::sprite_size() const -> bool {
|
||||
return check_bit(control_byte, 2);
|
||||
}
|
||||
auto Video::sprites_enabled() const -> bool {
|
||||
return check_bit(control_byte, 1);
|
||||
}
|
||||
auto Video::bg_enabled() const -> bool {
|
||||
return check_bit(control_byte, 0);
|
||||
}
|
||||
|
||||
void Video::write_scanline(u8 current_line) {
|
||||
if(!display_enabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* Lines the frontend never displays are not worth rendering */
|
||||
if(row_mask && current_line < GAMEBOY_HEIGHT && !row_mask[current_line]) {
|
||||
return;
|
||||
}
|
||||
|
||||
if(bg_enabled()) {
|
||||
draw_bg_line(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);
|
||||
}
|
||||
}
|
||||
|
||||
void Video::draw_bg_line(uint current_line) {
|
||||
/* Note: tileset two uses signed numbering to share half the tiles with
|
||||
* tileset one */
|
||||
bool use_tile_set_zero = bg_window_tile_data();
|
||||
bool use_tile_map_zero = !bg_tile_map_display();
|
||||
|
||||
Palette palette = load_palette(bg_palette);
|
||||
|
||||
u16 tile_set_address = use_tile_set_zero ? TILE_SET_ZERO_ADDRESS : TILE_SET_ONE_ADDRESS;
|
||||
u16 tile_map_address = use_tile_map_zero ? TILE_MAP_ZERO_ADDRESS : TILE_MAP_ONE_ADDRESS;
|
||||
|
||||
uint screen_y = current_line;
|
||||
uint scrolled_y = (screen_y + scroll_y.value()) % BG_MAP_SIZE;
|
||||
uint tile_y = scrolled_y / TILE_HEIGHT_PX;
|
||||
uint tile_pixel_y = scrolled_y % TILE_HEIGHT_PX;
|
||||
uint tile_data_line_offset = tile_pixel_y * 2;
|
||||
|
||||
/* Render tile-by-tile instead of refetching the tile data for every
|
||||
* pixel like upstream did */
|
||||
uint scroll_x_val = scroll_x.value();
|
||||
|
||||
uint screen_x = 0;
|
||||
while(screen_x < GAMEBOY_WIDTH) {
|
||||
uint scrolled_x = (screen_x + scroll_x_val) % BG_MAP_SIZE;
|
||||
uint tile_x = scrolled_x / TILE_WIDTH_PX;
|
||||
uint tile_pixel_x = scrolled_x % TILE_WIDTH_PX;
|
||||
|
||||
uint tile_index = tile_y * TILES_PER_LINE + tile_x;
|
||||
u8 tile_id = video_ram[tile_map_address - 0x8000 + tile_index];
|
||||
|
||||
uint tile_data_mem_offset = use_tile_set_zero ?
|
||||
tile_id * TILE_BYTES :
|
||||
static_cast<uint>(
|
||||
(static_cast<s8>(tile_id) + 128)) *
|
||||
TILE_BYTES;
|
||||
|
||||
uint line_addr = (tile_set_address - 0x8000) + tile_data_mem_offset +
|
||||
tile_data_line_offset;
|
||||
|
||||
u8 pixels_1 = video_ram[line_addr];
|
||||
u8 pixels_2 = video_ram[line_addr + 1];
|
||||
|
||||
/* Draw the remainder of this tile's row */
|
||||
for(uint px = tile_pixel_x; px < TILE_WIDTH_PX && screen_x < GAMEBOY_WIDTH;
|
||||
px++, screen_x++) {
|
||||
u8 pixel_color = get_pixel_from_line(pixels_1, pixels_2, static_cast<u8>(px));
|
||||
buffer.set_pixel(screen_x, screen_y, get_shade_from_palette(pixel_color, palette));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
Palette palette = load_palette(bg_palette);
|
||||
|
||||
u16 tile_set_address = use_tile_set_zero ? TILE_SET_ZERO_ADDRESS : TILE_SET_ONE_ADDRESS;
|
||||
u16 tile_map_address = use_tile_map_zero ? TILE_MAP_ZERO_ADDRESS : TILE_MAP_ONE_ADDRESS;
|
||||
|
||||
uint screen_y = current_line;
|
||||
uint scrolled_y = screen_y - window_y.value();
|
||||
|
||||
if(scrolled_y >= GAMEBOY_HEIGHT) {
|
||||
return;
|
||||
}
|
||||
|
||||
uint tile_y = scrolled_y / TILE_HEIGHT_PX;
|
||||
uint tile_pixel_y = scrolled_y % TILE_HEIGHT_PX;
|
||||
uint tile_data_line_offset = tile_pixel_y * 2;
|
||||
|
||||
for(uint screen_x = 0; screen_x < GAMEBOY_WIDTH; screen_x++) {
|
||||
uint scrolled_x = screen_x + window_x.value() - 7;
|
||||
|
||||
uint tile_x = scrolled_x / TILE_WIDTH_PX;
|
||||
uint tile_pixel_x = scrolled_x % TILE_WIDTH_PX;
|
||||
|
||||
uint tile_index = tile_y * TILES_PER_LINE + tile_x;
|
||||
if(tile_index >= 32 * 32) continue;
|
||||
|
||||
u8 tile_id = video_ram[tile_map_address - 0x8000 + tile_index];
|
||||
|
||||
uint tile_data_mem_offset = use_tile_set_zero ?
|
||||
tile_id * TILE_BYTES :
|
||||
static_cast<uint>(
|
||||
(static_cast<s8>(tile_id) + 128)) *
|
||||
TILE_BYTES;
|
||||
|
||||
uint line_addr = (tile_set_address - 0x8000) + tile_data_mem_offset +
|
||||
tile_data_line_offset;
|
||||
|
||||
u8 pixels_1 = video_ram[line_addr];
|
||||
u8 pixels_2 = video_ram[line_addr + 1];
|
||||
|
||||
u8 pixel_color = get_pixel_from_line(pixels_1, pixels_2, static_cast<u8>(tile_pixel_x));
|
||||
buffer.set_pixel(screen_x, screen_y, get_shade_from_palette(pixel_color, palette));
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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];
|
||||
|
||||
/* 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);
|
||||
|
||||
Palette palette = use_palette_1 ? load_palette(sprite_palette_1) :
|
||||
load_palette(sprite_palette_0);
|
||||
|
||||
uint tile_offset = pattern_n * TILE_BYTES;
|
||||
|
||||
int start_y = sprite_y - 16;
|
||||
int start_x = sprite_x - 8;
|
||||
|
||||
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;
|
||||
|
||||
uint src_y = !flip_y ? y : sprite_height - y - 1;
|
||||
|
||||
uint line_addr = tile_offset + src_y * 2; /* relative to tile set zero */
|
||||
u8 pixels_1 = video_ram[line_addr];
|
||||
u8 pixels_2 = video_ram[line_addr + 1];
|
||||
|
||||
for(uint x = 0; x < TILE_WIDTH_PX; x++) {
|
||||
int screen_x = start_x + static_cast<int>(x);
|
||||
if(screen_x < 0 || screen_x >= static_cast<int>(GAMEBOY_WIDTH)) continue;
|
||||
|
||||
uint src_x = !flip_x ? x : TILE_WIDTH_PX - x - 1;
|
||||
|
||||
u8 gb_color = get_pixel_from_line(pixels_1, pixels_2, static_cast<u8>(src_x));
|
||||
|
||||
/* Color 0 is transparent */
|
||||
if(gb_color == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Shade existing_pixel = buffer.get_pixel(
|
||||
static_cast<uint>(screen_x), static_cast<uint>(screen_y));
|
||||
|
||||
/* Note: same behaviour as upstream - compares the final shade
|
||||
* rather than the logical color 0 */
|
||||
if(obj_behind_bg && existing_pixel != SHADE_WHITE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
buffer.set_pixel(
|
||||
static_cast<uint>(screen_x),
|
||||
static_cast<uint>(screen_y),
|
||||
get_shade_from_palette(gb_color, palette));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auto Video::get_pixel_from_line(u8 byte1, u8 byte2, u8 pixel_index) -> u8 {
|
||||
using bitwise::bit_value;
|
||||
|
||||
return static_cast<u8>(
|
||||
(bit_value(byte2, 7 - pixel_index) << 1) | bit_value(byte1, 7 - pixel_index));
|
||||
}
|
||||
|
||||
auto Video::load_palette(const ByteRegister& palette_register) -> Palette {
|
||||
u8 v = palette_register.value();
|
||||
|
||||
Palette palette;
|
||||
palette.color0 = static_cast<Shade>(v & 0x3);
|
||||
palette.color1 = static_cast<Shade>((v >> 2) & 0x3);
|
||||
palette.color2 = static_cast<Shade>((v >> 4) & 0x3);
|
||||
palette.color3 = static_cast<Shade>((v >> 6) & 0x3);
|
||||
return palette;
|
||||
}
|
||||
|
||||
auto Video::get_shade_from_palette(u8 color, const Palette& palette) -> Shade {
|
||||
switch(color) {
|
||||
case 0:
|
||||
return palette.color0;
|
||||
case 1:
|
||||
return palette.color1;
|
||||
case 2:
|
||||
return palette.color2;
|
||||
default:
|
||||
return palette.color3;
|
||||
}
|
||||
}
|
||||
|
||||
void Video::draw() {
|
||||
if(vblank_callback) vblank_callback(vblank_ctx);
|
||||
}
|
||||
Binary file not shown.
@@ -3,7 +3,7 @@
|
||||
* Usage: hosttest <rom.gb> [max_frames] [--dump-frame N]
|
||||
*/
|
||||
|
||||
#include "../gb/gameboy.h"
|
||||
#include "../lib/gbcore/gameboy.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
@@ -29,9 +29,9 @@ static void serial_hook(u8 byte) {
|
||||
}
|
||||
}
|
||||
|
||||
static const u8* bank_provider(void* /*ctx*/, uint bank) {
|
||||
long offset = static_cast<long>(bank) * 0x4000;
|
||||
if(offset + 0x4000 > 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;
|
||||
}
|
||||
|
||||
@@ -98,10 +98,32 @@ int main(int argc, char** argv) {
|
||||
bool dump = false;
|
||||
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 */
|
||||
@@ -146,12 +168,17 @@ int main(int argc, char** argv) {
|
||||
auto* gb = new Gameboy(g_rom, banks, mbc, cart_ram, ram_size, bank_provider, nullptr);
|
||||
gb->set_frame_callback(frame_hook, gb);
|
||||
if(rowmask) gb->set_row_mask(mask);
|
||||
if(skiprender) gb->set_skip_render(true);
|
||||
|
||||
u32 last_freq = 0;
|
||||
int last_ch = -1;
|
||||
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) {
|
||||
|
||||
+11
-10
@@ -1,15 +1,16 @@
|
||||
/* 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"
|
||||
|
||||
/* One frame-sequencer step every 2048 M-cycles (8192 T-cycles = 512 Hz),
|
||||
* in the same M-cycle domain the CPU/PPU/timer now share. */
|
||||
static const uint FRAME_SEQ_PERIOD = 2048;
|
||||
|
||||
void Apu::tick(uint cycles) {
|
||||
if(!power) return;
|
||||
|
||||
seq_counter += cycles;
|
||||
while(seq_counter >= FRAME_SEQ_PERIOD) {
|
||||
seq_counter -= FRAME_SEQ_PERIOD;
|
||||
/* Slow path of Apu::tick (see apu.h): runs the due frame-sequencer steps */
|
||||
void Apu::seq_run() {
|
||||
while(seq_counter >= 2048) {
|
||||
seq_counter -= 2048;
|
||||
seq_step = (u8)((seq_step + 1) & 7);
|
||||
|
||||
if((seq_step & 1) == 0) clock_lengths(); /* 256 Hz */
|
||||
+76
-1
@@ -28,7 +28,14 @@ struct ApuVoice {
|
||||
|
||||
class Apu {
|
||||
public:
|
||||
void tick(uint cycles);
|
||||
/* Called once per emulated instruction; the fast path (powered off, or
|
||||
* no sequencer step due) must stay inline and branch-cheap. One
|
||||
* frame-sequencer step every 2048 M-cycles = 8192 T-cycles = 512 Hz. */
|
||||
void tick(uint cycles) {
|
||||
if(!power) return;
|
||||
seq_counter += cycles;
|
||||
if(seq_counter >= 2048) seq_run();
|
||||
}
|
||||
|
||||
/* 0xFF10 - 0xFF3F (sound registers + wave RAM) */
|
||||
auto read(u16 addr) const -> u8;
|
||||
@@ -37,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;
|
||||
@@ -81,6 +155,7 @@ private:
|
||||
}
|
||||
|
||||
void trigger(uint n);
|
||||
void seq_run();
|
||||
void clock_lengths();
|
||||
void clock_envelopes();
|
||||
void clock_sweep();
|
||||
+92
-4
@@ -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(
|
||||
@@ -21,6 +28,7 @@ void Cartridge::init(
|
||||
bank_low = 1;
|
||||
bank_high = 0;
|
||||
ram_bank = 0;
|
||||
cur_bank = 0xFFFFFFFFu;
|
||||
|
||||
update_rom_bank();
|
||||
}
|
||||
@@ -36,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;
|
||||
@@ -51,7 +64,22 @@ void Cartridge::update_rom_bank() {
|
||||
|
||||
if(bank_count) bank %= bank_count;
|
||||
|
||||
bankN = provider(provider_ctx, bank);
|
||||
/* 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;
|
||||
bankN_q[0] = bankN_q[1] = bankN_q[2] = bankN_q[3] = nullptr;
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -64,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;
|
||||
@@ -109,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;
|
||||
|
||||
@@ -140,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 {
|
||||
@@ -0,0 +1,152 @@
|
||||
#pragma once
|
||||
|
||||
#include "definitions.h"
|
||||
|
||||
/* Cartridge with pluggable ROM page provider.
|
||||
*
|
||||
* Instead of holding the whole ROM in RAM (impossible on Flipper Zero for
|
||||
* anything above 32 KB), the cartridge asks the platform for pointers to
|
||||
* 8 KB ROM pages (page n = ROM offset n * 0x2000) whenever the game
|
||||
* switches banks. 8 KB granularity -- half a MBC bank -- doubles how many
|
||||
* cache slots fit in the same RAM and halves the SD stall of a cache miss,
|
||||
* which matters a lot for bank-switch heavy games (Pokemon switches banks
|
||||
* for music/code/data every frame). On the desktop test build the provider
|
||||
* just returns `rom + page * 0x2000`; on the Flipper it is backed by an
|
||||
* LRU cache streaming from the SD card.
|
||||
*
|
||||
* Supported mappers: ROM only, MBC1 (incl. upper bits / mode select),
|
||||
* MBC2 (built-in 512x4 RAM), MBC3 (no RTC), MBC5.
|
||||
*/
|
||||
|
||||
/* 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 */
|
||||
void init(
|
||||
const u8* bank0_data,
|
||||
uint rom_bank_count,
|
||||
MBCType mbc_type,
|
||||
u8* cart_ram,
|
||||
u32 cart_ram_size,
|
||||
RomBankProvider provider,
|
||||
void* provider_ctx);
|
||||
|
||||
auto read(u16 addr) const -> u8 {
|
||||
if(addr < 0x4000) return bank0[addr];
|
||||
if(addr < 0x8000) {
|
||||
/* 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);
|
||||
}
|
||||
|
||||
void write(u16 addr, u8 value);
|
||||
|
||||
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;
|
||||
static auto rom_bank_count_from_header(u8 rom_size_byte) -> uint;
|
||||
static auto ram_size_from_header(u8 ram_size_byte, MBCType mbc) -> u32;
|
||||
|
||||
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();
|
||||
|
||||
const u8* bank0 = nullptr;
|
||||
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;
|
||||
|
||||
RomBankProvider provider = nullptr;
|
||||
void* provider_ctx = nullptr;
|
||||
|
||||
MBCType mbc = MBCType::None;
|
||||
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 */
|
||||
uint ram_bank = 0;
|
||||
};
|
||||
+66
-45
@@ -29,18 +29,52 @@ void CPU::init_post_boot() {
|
||||
}
|
||||
|
||||
auto CPU::tick() -> Cycles {
|
||||
handle_interrupts();
|
||||
/* STOP: the CPU core is frozen until joypad activity (notify_joypad).
|
||||
* Interrupt flags raised meanwhile stay pending. */
|
||||
if (stopped) { return 8; }
|
||||
|
||||
/* Halted: batch 4 M-cycles per iteration. Games spend most of every
|
||||
/* Interrupt fast path: nothing pending for the enabled sources (the
|
||||
* overwhelmingly common case) costs two loads and a branch. Bits 5-7
|
||||
* of IF/IE are unwired on hardware and must be masked: without the
|
||||
* 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) {
|
||||
/* 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
|
||||
* the idle part of the frame as expensive to emulate as the busy part.
|
||||
* Interrupt recognition is delayed by at most 3 M-cycles (12 T-cycles),
|
||||
* Interrupt recognition is delayed by at most 7 M-cycles (28 T-cycles),
|
||||
* well within what real hardware tolerates. */
|
||||
if (halted) { return 4; }
|
||||
if (halted) { return 8; }
|
||||
|
||||
/* EI enables IME only AFTER the instruction that follows it. Without
|
||||
* this delay, the classic pause idiom `EI / HALT` with an interrupt
|
||||
* already pending dispatched BETWEEN the two: the ISR consumed the
|
||||
* wake event, RETI returned onto the HALT, and the game slept with
|
||||
* its wake condition already spent -- input appeared permanently dead
|
||||
* while the vblank ISR (music) kept running. */
|
||||
bool ei_was_pending = ime_pending;
|
||||
|
||||
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) {
|
||||
/* not cancelled by a DI in the delay slot */
|
||||
interrupts_enabled = true;
|
||||
ime_pending = false;
|
||||
}
|
||||
return cycles;
|
||||
}
|
||||
|
||||
@@ -55,37 +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 = interrupt_flag.value() & interrupt_enabled.value();
|
||||
if (!fired_interrupts) { return; }
|
||||
|
||||
if (halted && fired_interrupts != 0x0) {
|
||||
// TODO: Handle halt bug
|
||||
auto CPU::handle_interrupts(u8 fired_interrupts) -> bool {
|
||||
if (halted) {
|
||||
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 {
|
||||
@@ -96,27 +116,20 @@ 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;
|
||||
}
|
||||
|
||||
auto CPU::get_byte_from_pc() -> u8 {
|
||||
u8 byte = gb.mmu.read(Address(pc));
|
||||
pc.increment();
|
||||
|
||||
return byte;
|
||||
}
|
||||
|
||||
auto CPU::get_signed_byte_from_pc() -> s8 {
|
||||
u8 byte = get_byte_from_pc();
|
||||
return static_cast<s8>(byte);
|
||||
}
|
||||
|
||||
auto CPU::get_word_from_pc() -> u16 {
|
||||
u8 low_byte = get_byte_from_pc();
|
||||
u8 high_byte = get_byte_from_pc();
|
||||
|
||||
return compose_bytes(high_byte, low_byte);
|
||||
}
|
||||
/* get_byte_from_pc / get_signed_byte_from_pc / get_word_from_pc are the
|
||||
* hottest functions in the emulator (every instruction fetches through
|
||||
* them): they are defined inline at the bottom of gameboy.h. */
|
||||
|
||||
void CPU::set_flag_zero(bool set) { f.set_flag_zero(set); }
|
||||
void CPU::set_flag_subtract(bool set) { f.set_flag_subtract(set); }
|
||||
@@ -223,3 +236,11 @@ auto CPU::execute_cb_opcode(const u8 opcode, u16 opcode_pc) -> Cycles {
|
||||
|
||||
return opcode_cycles_cb[opcode];
|
||||
}
|
||||
|
||||
/* Unity build: the opcode implementations are compiled inside this same
|
||||
* translation unit so the dispatch switches above can call (and inline)
|
||||
* them directly. As separate TUs, every emulated instruction paid two
|
||||
* cross-TU calls (dispatch -> opcode_XX -> opcode helper), which the
|
||||
* compiler could not eliminate without LTO. */
|
||||
#include "opcode_mapping.inc"
|
||||
#include "opcodes.inc"
|
||||
+47
-1
@@ -51,14 +51,60 @@ 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();
|
||||
auto handle_interrupts(u8 fired_interrupts) -> bool; /* true = dispatched */
|
||||
auto handle_interrupt(u8 interrupt_bit, u16 interrupt_vector, u8 fired_interrupts) -> bool;
|
||||
|
||||
Gameboy& gb;
|
||||
|
||||
bool interrupts_enabled = false;
|
||||
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:
|
||||
* STOP exits on a joypad line change regardless of IE/IF/IME). */
|
||||
void notify_joypad() {
|
||||
if(stopped) {
|
||||
stopped = false;
|
||||
halted = false;
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
|
||||
bool branch_taken = false;
|
||||
|
||||
+1
@@ -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) */
|
||||
|
||||
+9
@@ -61,6 +61,15 @@ public:
|
||||
* storage slot s starts at bit offset s * GAMEBOY_WIDTH * 2. */
|
||||
auto raw() const -> const u8* { return buf; }
|
||||
|
||||
/* Direct pointer to the packed bytes of screen row y (40 bytes,
|
||||
* 4 pixels each, LSB-first) or nullptr if the row has no storage.
|
||||
* Used by the byte-oriented renderer in video.cc. */
|
||||
auto row_ptr(uint y) -> u8* {
|
||||
uint slot = row_slot[y];
|
||||
if(slot == NO_ROW) return nullptr;
|
||||
return buf + slot * (GAMEBOY_WIDTH / 4);
|
||||
}
|
||||
|
||||
private:
|
||||
static const u8 NO_ROW = 0xFF;
|
||||
|
||||
+9
-10
@@ -43,25 +43,24 @@ 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/STOP for input. */
|
||||
cpu.interrupt_flag.set_bit_to(4, true);
|
||||
/* 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() {
|
||||
/* Gameboy::tick and the peripheral ticks it calls are defined inline
|
||||
* in gameboy.h, so this loop compiles into one flattened body with a
|
||||
* single out-of-line call per instruction (cpu.tick). */
|
||||
frame_done = false;
|
||||
while(!frame_done) {
|
||||
tick();
|
||||
}
|
||||
}
|
||||
|
||||
void Gameboy::tick() {
|
||||
auto cycles = cpu.tick();
|
||||
video.tick(cycles);
|
||||
timer.tick(cycles.cycles);
|
||||
apu.tick(cycles.cycles);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
#pragma once
|
||||
|
||||
#include "input.h"
|
||||
#include "cpu.h"
|
||||
#include "video.h"
|
||||
#include "timer.h"
|
||||
#include "mmu.h"
|
||||
#include "cartridge.h"
|
||||
#include "apu.h"
|
||||
|
||||
class Gameboy {
|
||||
public:
|
||||
/* bank0: pointer to the first 16 KB of ROM (stays resident).
|
||||
* provider: returns pointers to 16 KB switchable banks. */
|
||||
Gameboy(
|
||||
const u8* bank0,
|
||||
uint rom_bank_count,
|
||||
MBCType mbc,
|
||||
u8* cart_ram,
|
||||
u32 cart_ram_size,
|
||||
RomBankProvider provider,
|
||||
void* provider_ctx);
|
||||
|
||||
/* Runs the emulator until the next vblank (one full frame). */
|
||||
void run_to_vblank();
|
||||
|
||||
/* Called on every vblank BEFORE the framebuffer is cleared for the next
|
||||
* frame: this is where the frontend must convert/copy the image. */
|
||||
void set_frame_callback(void (*cb)(void*), void* ctx) {
|
||||
user_frame_cb = cb;
|
||||
user_frame_ctx = ctx;
|
||||
}
|
||||
|
||||
void button_pressed(GbButton button);
|
||||
void button_released(GbButton button);
|
||||
|
||||
void set_skip_render(bool skip) { video.skip_render = skip; }
|
||||
|
||||
/* Flush pending peripheral cycles. Called automatically every
|
||||
* PERIPH_BATCH M-cycles and before any IO register read/write so
|
||||
* LY/STAT/DIV/TIMA/IF are always fresh where the game can see them. */
|
||||
void sync_peripherals() {
|
||||
uint n = pending_cycles;
|
||||
if(n) {
|
||||
pending_cycles = 0;
|
||||
video.tick(Cycles(n));
|
||||
timer.tick(n);
|
||||
apu.tick(n);
|
||||
mmu.serial_tick(n);
|
||||
}
|
||||
}
|
||||
|
||||
/* Optional display-line mask (see Video::row_mask). The array must stay
|
||||
* valid for the lifetime of the emulator. */
|
||||
void set_row_mask(const u8* mask) { video.set_row_mask(mask); }
|
||||
auto get_framebuffer() const -> const FrameBuffer& { return video.get_framebuffer(); }
|
||||
|
||||
auto get_cartridge_ram() -> u8* { return cartridge.get_ram(); }
|
||||
auto get_cartridge_ram_size() const -> u32 { return cartridge.get_ram_size(); }
|
||||
|
||||
Cartridge cartridge;
|
||||
|
||||
CPU cpu;
|
||||
friend class CPU;
|
||||
|
||||
Video video;
|
||||
friend class Video;
|
||||
|
||||
MMU mmu;
|
||||
friend class MMU;
|
||||
|
||||
Timer timer;
|
||||
friend class Timer;
|
||||
|
||||
Apu apu;
|
||||
|
||||
Input input;
|
||||
|
||||
private:
|
||||
void tick();
|
||||
|
||||
/* Peripheral catch-up batching (the single biggest hot-loop win on the
|
||||
* Cortex-M4): instead of stepping the PPU/timer/APU state machines
|
||||
* after every CPU instruction, cycles accumulate and are flushed every
|
||||
* PERIPH_BATCH M-cycles -- or immediately whenever the CPU touches an
|
||||
* IO register. Interrupt delivery is delayed by at most PERIPH_BATCH
|
||||
* M-cycles (128 T-cycles), far below what games can observe, while IO
|
||||
* reads always see exact values thanks to the sync points. */
|
||||
static const uint PERIPH_BATCH = 32;
|
||||
uint pending_cycles = 0;
|
||||
|
||||
static void vblank_trampoline(void* ctx);
|
||||
volatile bool frame_done = false;
|
||||
|
||||
void (*user_frame_cb)(void*) = nullptr;
|
||||
void* user_frame_ctx = nullptr;
|
||||
};
|
||||
|
||||
/* ==================== hot-path inline implementations ====================
|
||||
*
|
||||
* Everything below runs once (or more) per emulated instruction. These
|
||||
* bodies need the complete Gameboy definition, and they are defined here
|
||||
* -- instead of in their .cc files -- so the core can inline them without
|
||||
* LTO. Before this, each emulated instruction paid 6-8 cross-TU function
|
||||
* calls (opcode fetch through the MMU, PPU/timer/APU ticks), which
|
||||
* dominated the frame time on the Cortex-M4.
|
||||
*
|
||||
* Deliberately NOT inlined: the full MMU read/write dispatch. Inlining it
|
||||
* into the ~300 opcode call sites costs ~15 KB of code (= RAM on the
|
||||
* Flipper, where the binary loads into the same heap as the ROM cache).
|
||||
* Only the instruction-fetch fast path (PC in ROM, true for virtually
|
||||
* every instruction) is inlined below. */
|
||||
|
||||
inline void Video::tick(Cycles cycles) {
|
||||
cycle_counter += cycles.cycles;
|
||||
|
||||
/* Fast path: no mode boundary crossed. Boundary work (interrupts,
|
||||
* scanline rendering) is out-of-line in video.cc. The counter wrap
|
||||
* uses subtraction instead of the old modulo: per-instruction cycle
|
||||
* increments (<= 6) can never overshoot a whole extra period. */
|
||||
switch(current_mode) {
|
||||
case VideoMode::ACCESS_OAM:
|
||||
if(cycle_counter >= CLOCKS_PER_SCANLINE_OAM) {
|
||||
cycle_counter -= CLOCKS_PER_SCANLINE_OAM;
|
||||
lcd_status.set_bit_to(1, true);
|
||||
lcd_status.set_bit_to(0, true);
|
||||
current_mode = VideoMode::ACCESS_VRAM;
|
||||
}
|
||||
break;
|
||||
case VideoMode::ACCESS_VRAM:
|
||||
if(cycle_counter >= CLOCKS_PER_SCANLINE_VRAM) {
|
||||
cycle_counter -= CLOCKS_PER_SCANLINE_VRAM;
|
||||
mode_transition_vram_end();
|
||||
}
|
||||
break;
|
||||
case VideoMode::HBLANK:
|
||||
if(cycle_counter >= CLOCKS_PER_HBLANK) {
|
||||
cycle_counter -= CLOCKS_PER_HBLANK;
|
||||
mode_transition_hblank_end();
|
||||
}
|
||||
break;
|
||||
case VideoMode::VBLANK:
|
||||
if(cycle_counter >= CLOCKS_PER_SCANLINE) {
|
||||
cycle_counter -= CLOCKS_PER_SCANLINE;
|
||||
mode_transition_vblank_line();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
inline void Timer::tick(uint cycles) {
|
||||
/* DIV increments at 16384 Hz = every 64 M-cycles */
|
||||
div_clocks += cycles;
|
||||
if(div_clocks >= 64) {
|
||||
divider.set(static_cast<u8>(divider.value() + (div_clocks >> 6)));
|
||||
div_clocks &= 63;
|
||||
}
|
||||
|
||||
/* 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();
|
||||
/* a long instruction can cross more than one period of the fastest
|
||||
* (16 T-cycle) timer rate; the old modulo silently dropped those */
|
||||
while(clocks >= clock_limit) {
|
||||
clocks -= clock_limit;
|
||||
|
||||
u8 old_timer_counter = timer_counter.value();
|
||||
timer_counter.increment();
|
||||
|
||||
if(timer_counter.value() < old_timer_counter) {
|
||||
gb.cpu.interrupt_flag.set_bit_to(2, true);
|
||||
timer_counter.set(timer_modulo.value());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
/* PC sits in cartridge ROM for virtually every instruction: read the
|
||||
* mapped page directly and skip the full MMU dispatch */
|
||||
if(a < 0x8000) return gb.cartridge.read(a);
|
||||
return gb.mmu.read(Address(a));
|
||||
}
|
||||
|
||||
inline auto CPU::get_signed_byte_from_pc() -> s8 {
|
||||
return static_cast<s8>(get_byte_from_pc());
|
||||
}
|
||||
|
||||
inline auto CPU::get_word_from_pc() -> u16 {
|
||||
u8 low_byte = get_byte_from_pc();
|
||||
u8 high_byte = get_byte_from_pc();
|
||||
return static_cast<u16>((high_byte << 8) | low_byte);
|
||||
}
|
||||
|
||||
inline void Gameboy::tick() {
|
||||
pending_cycles += cpu.tick().cycles;
|
||||
if(pending_cycles >= PERIPH_BATCH) sync_peripherals();
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/* 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"
|
||||
|
||||
void Input::button_pressed(GbButton button) {
|
||||
set_button(button, true);
|
||||
}
|
||||
|
||||
void Input::button_released(GbButton button) {
|
||||
set_button(button, false);
|
||||
}
|
||||
|
||||
void Input::set_button(GbButton button, bool set) {
|
||||
if (button == GbButton::Up) { up = set; }
|
||||
if (button == GbButton::Down) { down = set; }
|
||||
if (button == GbButton::Left) { left = set; }
|
||||
if (button == GbButton::Right) { right = set; }
|
||||
if (button == GbButton::A) { a = set; }
|
||||
if (button == GbButton::B) { b = set; }
|
||||
if (button == GbButton::Select) { select = set; }
|
||||
if (button == GbButton::Start) { start = set; }
|
||||
}
|
||||
|
||||
void Input::write(u8 set) {
|
||||
using bitwise::check_bit;
|
||||
|
||||
direction_switch = !check_bit(set, 4);
|
||||
button_switch = !check_bit(set, 5);
|
||||
}
|
||||
|
||||
auto Input::get_input() const -> u8 {
|
||||
using bitwise::set_bit_to;
|
||||
|
||||
/* 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) {
|
||||
/* 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) {
|
||||
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);
|
||||
|
||||
/* bits 6-7 are unwired and read as 1 on hardware */
|
||||
return static_cast<u8>(buttons | 0xC0);
|
||||
}
|
||||
+8
@@ -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);
|
||||
|
||||
+30
-10
@@ -94,6 +94,9 @@ void MMU::write(const Address& address, u8 byte) {
|
||||
}
|
||||
|
||||
auto MMU::read_io(const Address& address) const -> u8 {
|
||||
/* catch-up: make LY/STAT/DIV/TIMA/IF/NRxx exact before the game reads */
|
||||
gb.sync_peripherals();
|
||||
|
||||
u16 a = address.value();
|
||||
|
||||
/* Sound registers + wave RAM */
|
||||
@@ -107,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();
|
||||
@@ -122,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();
|
||||
@@ -137,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();
|
||||
@@ -167,6 +174,9 @@ auto MMU::read_io(const Address& address) const -> u8 {
|
||||
}
|
||||
|
||||
void MMU::write_io(const Address& address, u8 byte) {
|
||||
/* catch-up: apply pending time before the write changes IO state */
|
||||
gb.sync_peripherals();
|
||||
|
||||
u16 a = address.value();
|
||||
|
||||
/* Sound registers + wave RAM */
|
||||
@@ -185,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:
|
||||
@@ -216,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:
|
||||
@@ -228,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:
|
||||
@@ -0,0 +1,59 @@
|
||||
#pragma once
|
||||
|
||||
#include "address.h"
|
||||
#include "definitions.h"
|
||||
|
||||
class Gameboy;
|
||||
|
||||
/* Serial output hook, used by the host test harness to capture Blargg test
|
||||
* ROM output. Null on the Flipper build. */
|
||||
extern "C" {
|
||||
extern void (*gb_serial_hook)(u8 byte);
|
||||
}
|
||||
|
||||
class MMU {
|
||||
public:
|
||||
MMU(Gameboy& inGb);
|
||||
|
||||
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);
|
||||
void dma_transfer(u8 byte);
|
||||
|
||||
Gameboy& gb;
|
||||
|
||||
u8 work_ram[0x2000] = {}; /* DMG: 8 KB (was 32 KB upstream) */
|
||||
u8 oam_ram[0xA0] = {};
|
||||
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;
|
||||
};
|
||||
+15
-8
@@ -3,6 +3,13 @@
|
||||
|
||||
#include <array>
|
||||
|
||||
/* NOTE: undefined opcodes (0xD3, 0xDB, 0xDD, 0xE3, 0xE4, 0xEB, 0xEC, 0xED,
|
||||
* 0xF4, 0xFC, 0xFD) and the 0xCB prefix placeholder cost 1 M-cycle here,
|
||||
* NOT 0. With 0-cycle entries, executing garbage (bad jump, corrupted
|
||||
* read) advanced the PC without advancing the PPU/timer clocks, so
|
||||
* run_to_vblank() could spin without ever finishing a frame: on the
|
||||
* Flipper that froze the whole device until a hard reboot. */
|
||||
|
||||
const std::array<u8, 256> opcode_cycles = {
|
||||
1, 3, 2, 2, 1, 1, 2, 1, 5, 2, 2, 2, 1, 1, 2, 1,
|
||||
1, 3, 2, 2, 1, 1, 2, 1, 3, 2, 2, 2, 1, 1, 2, 1,
|
||||
@@ -16,10 +23,10 @@ const std::array<u8, 256> opcode_cycles = {
|
||||
1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 2, 1,
|
||||
1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 2, 1,
|
||||
1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 2, 1,
|
||||
2, 3, 3, 4, 3, 4, 2, 4, 2, 4, 3, 0, 3, 6, 2, 4,
|
||||
2, 3, 3, 0, 3, 4, 2, 4, 2, 4, 3, 0, 3, 0, 2, 4,
|
||||
3, 3, 2, 0, 0, 4, 2, 4, 4, 1, 4, 0, 0, 0, 2, 4,
|
||||
3, 3, 2, 1, 0, 4, 2, 4, 3, 2, 4, 1, 0, 0, 2, 4
|
||||
2, 3, 3, 4, 3, 4, 2, 4, 2, 4, 3, 1, 3, 6, 2, 4,
|
||||
2, 3, 3, 1, 3, 4, 2, 4, 2, 4, 3, 1, 3, 1, 2, 4,
|
||||
3, 3, 2, 1, 1, 4, 2, 4, 4, 1, 4, 1, 1, 1, 2, 4,
|
||||
3, 3, 2, 1, 1, 4, 2, 4, 3, 2, 4, 1, 1, 1, 2, 4
|
||||
};
|
||||
|
||||
const std::array<u8, 256> opcode_cycles_branched = {
|
||||
@@ -35,10 +42,10 @@ const std::array<u8, 256> opcode_cycles_branched = {
|
||||
1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 2, 1,
|
||||
1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 2, 1,
|
||||
1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 2, 1,
|
||||
5, 3, 4, 4, 6, 4, 2, 4, 5, 4, 4, 0, 6, 6, 2, 4,
|
||||
5, 3, 4, 0, 6, 4, 2, 4, 5, 4, 4, 0, 6, 0, 2, 4,
|
||||
3, 3, 2, 0, 0, 4, 2, 4, 4, 1, 4, 0, 0, 0, 2, 4,
|
||||
3, 3, 2, 1, 0, 4, 2, 4, 3, 2, 4, 1, 0, 0, 2, 4
|
||||
5, 3, 4, 4, 6, 4, 2, 4, 5, 4, 4, 1, 6, 6, 2, 4,
|
||||
5, 3, 4, 1, 6, 4, 2, 4, 5, 4, 4, 1, 6, 1, 2, 4,
|
||||
3, 3, 2, 1, 1, 4, 2, 4, 4, 1, 4, 1, 1, 1, 2, 4,
|
||||
3, 3, 2, 1, 1, 4, 2, 4, 3, 2, 4, 1, 1, 1, 2, 4
|
||||
};
|
||||
|
||||
const std::array<u8, 256> opcode_cycles_cb = {
|
||||
+1
@@ -1,4 +1,5 @@
|
||||
#include "cpu.h"
|
||||
#include "gameboy.h" /* inline hot-path definitions (operand fetch) */
|
||||
/* clang-format off */
|
||||
|
||||
/**
|
||||
+33
-8
@@ -262,12 +262,13 @@ void CPU::opcode_dec(Address&& addr) {
|
||||
/* DI */
|
||||
void CPU::opcode_di() {
|
||||
interrupts_enabled = false;
|
||||
ime_pending = false; /* DI in the EI delay slot cancels the enable */
|
||||
}
|
||||
|
||||
|
||||
/* EI */
|
||||
/* EI: IME is enabled after the NEXT instruction (see CPU::tick) */
|
||||
void CPU::opcode_ei() {
|
||||
interrupts_enabled = true;
|
||||
ime_pending = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -341,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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -543,10 +560,10 @@ void CPU::opcode_ret(Condition condition) {
|
||||
}
|
||||
|
||||
|
||||
/* RETI */
|
||||
/* RETI: unlike EI, re-enables IME immediately (no delay slot) */
|
||||
void CPU::opcode_reti() {
|
||||
opcode_ret();
|
||||
opcode_ei();
|
||||
interrupts_enabled = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -810,9 +827,17 @@ void CPU::opcode_srl(Address&& addr) {
|
||||
}
|
||||
|
||||
|
||||
/* STOP */
|
||||
/* STOP: on real DMG the CPU freezes until a joypad LINE CHANGE -- this is
|
||||
* independent of IE/IF/IME (it does not go through interrupt dispatch).
|
||||
* Modelling it as plain HALT was wrong twice over: with IE bit4 clear a
|
||||
* press never satisfied IF&IE so the game froze forever, and with IE
|
||||
* bit0 set our free-running PPU vblank woke it instantly. The `stopped`
|
||||
* flag is cleared only by CPU::notify_joypad() (wired to button presses).
|
||||
* STOP is also a 2-byte instruction: consume the padding byte. */
|
||||
void CPU::opcode_stop() {
|
||||
/* halted = true; */
|
||||
get_byte_from_pc(); /* skip the 0x00 pad */
|
||||
halted = true;
|
||||
stopped = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
#pragma once
|
||||
|
||||
#include "definitions.h"
|
||||
#include "register.h"
|
||||
|
||||
class Gameboy;
|
||||
|
||||
class Timer {
|
||||
public:
|
||||
Timer(Gameboy& inGb)
|
||||
: gb(inGb) {}
|
||||
|
||||
/* Called once per emulated instruction: defined inline at the bottom
|
||||
* of gameboy.h (needs the complete Gameboy type). */
|
||||
void tick(uint cycles);
|
||||
|
||||
auto get_divider() const -> u8 { return divider.value(); }
|
||||
auto get_timer() const -> u8 { return timer_counter.value(); }
|
||||
auto get_timer_modulo() const -> u8 { return timer_modulo.value(); }
|
||||
/* Only the bottom three bits of this register are usable */
|
||||
auto get_timer_control() const -> u8 { return timer_control.value() & 0x7; }
|
||||
|
||||
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); }
|
||||
|
||||
private:
|
||||
auto clocks_needed_to_increment() const -> uint {
|
||||
switch(get_timer_control() & 0x3) {
|
||||
case 0: return CLOCK_RATE / 4096;
|
||||
case 1: return CLOCK_RATE / 262144;
|
||||
case 2: return CLOCK_RATE / 65536;
|
||||
default: return CLOCK_RATE / 16384;
|
||||
}
|
||||
}
|
||||
|
||||
uint clocks = 0;
|
||||
uint div_clocks = 0;
|
||||
|
||||
Gameboy& gb;
|
||||
|
||||
ByteRegister divider;
|
||||
ByteRegister timer_counter;
|
||||
|
||||
ByteRegister timer_modulo;
|
||||
ByteRegister timer_control;
|
||||
};
|
||||
@@ -0,0 +1,412 @@
|
||||
#include "video.h"
|
||||
|
||||
#include "gameboy.h"
|
||||
#include "cpu.h"
|
||||
#include "bitwise.h"
|
||||
|
||||
using bitwise::check_bit;
|
||||
|
||||
/* ---------------------------------------------------------- tile decode */
|
||||
|
||||
/* 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.
|
||||
* 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 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;
|
||||
for(uint k = 0; k < 8; k++) {
|
||||
fwd |= (u16)(((b >> (7 - k)) & 1) << (2 * k));
|
||||
}
|
||||
s_tile_lut[b] = fwd;
|
||||
}
|
||||
s_luts_ready = true;
|
||||
}
|
||||
}
|
||||
|
||||
/* Palette LUT: maps a packed 2bpp byte (4 pixels) through BGP in one
|
||||
* lookup. Rebuilt only when the game writes a new BGP value. */
|
||||
__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] = {
|
||||
(u8)(bgp & 0x3),
|
||||
(u8)((bgp >> 2) & 0x3),
|
||||
(u8)((bgp >> 4) & 0x3),
|
||||
(u8)((bgp >> 6) & 0x3),
|
||||
};
|
||||
for(uint v = 0; v < 256; v++) {
|
||||
bgp_lut[v] = (u8)(
|
||||
shade[v & 3] | (shade[(v >> 2) & 3] << 2) | (shade[(v >> 4) & 3] << 4) |
|
||||
(shade[(v >> 6) & 3] << 6));
|
||||
}
|
||||
bgp_lut_cached_for = bgp;
|
||||
bgp_lut_valid = true;
|
||||
}
|
||||
return bgp_lut;
|
||||
}
|
||||
|
||||
/* Video::tick is called once per emulated instruction; it lives as an
|
||||
* inline definition at the bottom of gameboy.h. Mode-transition work
|
||||
* (rendering, interrupts) stays out-of-line in this file. */
|
||||
|
||||
__attribute__((optimize("Os"))) void Video::mode_transition_vram_end() {
|
||||
current_mode = VideoMode::HBLANK;
|
||||
|
||||
if(check_bit(lcd_status.value(), 3)) {
|
||||
gb.cpu.interrupt_flag.set_bit_to(1, true); /* hblank STAT */
|
||||
}
|
||||
|
||||
lcd_status.set_bit_to(1, false);
|
||||
lcd_status.set_bit_to(0, false);
|
||||
}
|
||||
|
||||
/* 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) {
|
||||
current_mode = VideoMode::VBLANK;
|
||||
lcd_status.set_bit_to(1, false);
|
||||
lcd_status.set_bit_to(0, true);
|
||||
gb.cpu.interrupt_flag.set_bit_to(0, true);
|
||||
} else {
|
||||
lcd_status.set_bit_to(1, true);
|
||||
lcd_status.set_bit_to(0, false);
|
||||
current_mode = VideoMode::ACCESS_OAM;
|
||||
}
|
||||
}
|
||||
|
||||
__attribute__((optimize("Os"))) void Video::mode_transition_vblank_line() {
|
||||
line.increment();
|
||||
|
||||
/* 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) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
auto Video::display_enabled() const -> bool {
|
||||
return check_bit(control_byte, 7);
|
||||
}
|
||||
auto Video::window_tile_map() const -> bool {
|
||||
return check_bit(control_byte, 6);
|
||||
}
|
||||
auto Video::window_enabled() const -> bool {
|
||||
return check_bit(control_byte, 5);
|
||||
}
|
||||
auto Video::bg_window_tile_data() const -> bool {
|
||||
return check_bit(control_byte, 4);
|
||||
}
|
||||
auto Video::bg_tile_map_display() const -> bool {
|
||||
return check_bit(control_byte, 3);
|
||||
}
|
||||
auto Video::sprite_size() const -> bool {
|
||||
return check_bit(control_byte, 2);
|
||||
}
|
||||
auto Video::sprites_enabled() const -> bool {
|
||||
return check_bit(control_byte, 1);
|
||||
}
|
||||
auto Video::bg_enabled() const -> bool {
|
||||
return check_bit(control_byte, 0);
|
||||
}
|
||||
|
||||
void Video::write_scanline(u8 current_line) {
|
||||
if(!display_enabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* Lines the frontend never displays are not worth rendering */
|
||||
if(row_mask && current_line < GAMEBOY_HEIGHT && !row_mask[current_line]) {
|
||||
return;
|
||||
}
|
||||
|
||||
if(bg_enabled()) {
|
||||
draw_bg_line(current_line);
|
||||
}
|
||||
|
||||
if(window_enabled()) {
|
||||
draw_window_line(current_line);
|
||||
}
|
||||
|
||||
/* 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);
|
||||
}
|
||||
}
|
||||
|
||||
/* Byte-oriented tile-strip renderer: emits `count` pixels of one tile-map
|
||||
* row into the packed framebuffer row `dst_row`, starting at screen pixel
|
||||
* `dst_x`, sourcing map pixels from `src_px` (wraps at 256). Pixels flow
|
||||
* through a small bit-queue so the bulk of the line is written as whole
|
||||
* palette-mapped bytes (4 px per store) instead of per-pixel RMW packing:
|
||||
* ~7x fewer operations per line than the old per-pixel path. */
|
||||
void Video::render_strip(
|
||||
u8* dst_row,
|
||||
uint dst_x,
|
||||
uint count,
|
||||
uint map_row_base, /* VRAM offset of the tile map row (32 entries) */
|
||||
uint src_px,
|
||||
uint tile_line_off, /* 2 * (row within the tile) */
|
||||
bool use_tile_set_zero,
|
||||
const u8* pal_lut) {
|
||||
uint tile_x = (src_px >> 3) & 31;
|
||||
uint fine = src_px & 7;
|
||||
uint tset_base = use_tile_set_zero ? 0x0000u : 0x0800u;
|
||||
|
||||
/* bit-queue of pending 2bpp pixels, LSB = next pixel to emit */
|
||||
u32 acc = 0;
|
||||
uint nbits = 0;
|
||||
|
||||
#define FETCH_TILE_ROW() \
|
||||
do { \
|
||||
u8 tid = video_ram[map_row_base + tile_x]; \
|
||||
tile_x = (tile_x + 1) & 31; \
|
||||
uint toff = use_tile_set_zero ? \
|
||||
(uint)tid * TILE_BYTES : \
|
||||
(uint)((s8)tid + 128) * TILE_BYTES; \
|
||||
uint la = tset_base + toff + tile_line_off; \
|
||||
u16 v = (u16)(s_tile_lut[video_ram[la]] | (s_tile_lut[video_ram[la + 1]] << 1)); \
|
||||
acc |= (u32)v << nbits; \
|
||||
nbits += 16; \
|
||||
} while(0)
|
||||
|
||||
/* prime the queue, discarding the fine-scroll pixels */
|
||||
FETCH_TILE_ROW();
|
||||
acc >>= fine * 2;
|
||||
nbits -= fine * 2;
|
||||
|
||||
/* head: single pixels until the destination is byte-aligned */
|
||||
while(count && (dst_x & 3)) {
|
||||
if(nbits < 2) FETCH_TILE_ROW();
|
||||
uint sh = (dst_x & 3) * 2;
|
||||
u8* p = dst_row + (dst_x >> 2);
|
||||
*p = (u8)((*p & ~(3u << sh)) | ((pal_lut[acc & 3] & 3u) << sh));
|
||||
acc >>= 2;
|
||||
nbits -= 2;
|
||||
dst_x++;
|
||||
count--;
|
||||
}
|
||||
|
||||
/* body: whole bytes, palette applied 4 pixels at a time */
|
||||
u8* out = dst_row + (dst_x >> 2);
|
||||
while(count >= 4) {
|
||||
if(nbits < 8) FETCH_TILE_ROW();
|
||||
*out++ = pal_lut[acc & 0xFF];
|
||||
acc >>= 8;
|
||||
nbits -= 8;
|
||||
count -= 4;
|
||||
dst_x += 4;
|
||||
}
|
||||
|
||||
/* tail */
|
||||
while(count) {
|
||||
if(nbits < 2) FETCH_TILE_ROW();
|
||||
uint sh = (dst_x & 3) * 2;
|
||||
u8* p = dst_row + (dst_x >> 2);
|
||||
*p = (u8)((*p & ~(3u << sh)) | ((pal_lut[acc & 3] & 3u) << sh));
|
||||
acc >>= 2;
|
||||
nbits -= 2;
|
||||
dst_x++;
|
||||
count--;
|
||||
}
|
||||
#undef FETCH_TILE_ROW
|
||||
}
|
||||
|
||||
void Video::draw_bg_line(uint current_line) {
|
||||
/* Note: tileset two uses signed numbering to share half the tiles with
|
||||
* tileset one */
|
||||
bool use_tile_set_zero = bg_window_tile_data();
|
||||
bool use_tile_map_zero = !bg_tile_map_display();
|
||||
|
||||
u8* dst = buffer.row_ptr(current_line);
|
||||
if(!dst) return;
|
||||
|
||||
uint scrolled_y = (current_line + scroll_y.value()) % BG_MAP_SIZE;
|
||||
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;
|
||||
|
||||
render_strip(
|
||||
dst,
|
||||
0,
|
||||
GAMEBOY_WIDTH,
|
||||
map_row_base,
|
||||
scroll_x.value(),
|
||||
(scrolled_y % TILE_HEIGHT_PX) * 2,
|
||||
use_tile_set_zero,
|
||||
bg_pal_lut());
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
/* the window only starts once LY reaches WY */
|
||||
if(current_line < window_y.value()) return;
|
||||
|
||||
/* 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; /* 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)) +
|
||||
((src_row / TILE_HEIGHT_PX) & 31) * TILES_PER_LINE;
|
||||
|
||||
render_strip(
|
||||
dst,
|
||||
start_x,
|
||||
GAMEBOY_WIDTH - start_x,
|
||||
map_row_base,
|
||||
wx >= 7 ? 0 : 7 - wx,
|
||||
(src_row % TILE_HEIGHT_PX) * 2,
|
||||
use_tile_set_zero,
|
||||
bg_pal_lut());
|
||||
}
|
||||
|
||||
void Video::draw_sprites_line(uint current_line) {
|
||||
uint sprite_height = sprite_size() ? 16 : 8;
|
||||
|
||||
/* 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;
|
||||
|
||||
u8* row = buffer.row_ptr(current_line);
|
||||
if(!row) return;
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
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 */
|
||||
|
||||
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);
|
||||
|
||||
/* in 8x16 mode the hardware ignores bit 0 of the tile id */
|
||||
if(sprite_height == 16) pattern_n &= 0xFE;
|
||||
|
||||
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),
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
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; /* transparent */
|
||||
|
||||
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);
|
||||
|
||||
/* 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Video::draw() {
|
||||
if(vblank_callback) vblank_callback(vblank_ctx);
|
||||
}
|
||||
+41
-6
@@ -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,12 +71,24 @@ 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);
|
||||
static auto get_pixel_from_line(u8 byte1, u8 byte2, u8 pixel_index) -> u8;
|
||||
void render_strip(
|
||||
u8* dst_row,
|
||||
uint dst_x,
|
||||
uint count,
|
||||
uint map_row_base,
|
||||
uint src_px,
|
||||
uint tile_line_off,
|
||||
bool use_tile_set_zero,
|
||||
const u8* pal_lut);
|
||||
auto bg_pal_lut() -> const u8*;
|
||||
|
||||
static auto is_on_screen(int x, int y) -> bool {
|
||||
return x >= 0 && y >= 0 && x < static_cast<int>(GAMEBOY_WIDTH) &&
|
||||
@@ -91,13 +104,35 @@ private:
|
||||
auto sprites_enabled() const -> bool;
|
||||
auto bg_enabled() const -> bool;
|
||||
|
||||
static auto load_palette(const ByteRegister& palette_register) -> Palette;
|
||||
static auto get_shade_from_palette(u8 color, const Palette& palette) -> Shade;
|
||||
|
||||
Gameboy& gb;
|
||||
|
||||
FrameBuffer buffer;
|
||||
|
||||
/* packed-byte -> palette-mapped-byte LUT for the current BGP value */
|
||||
u8 bgp_lut[256];
|
||||
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
@@ -13,12 +13,15 @@ def ProtoPirateDefineEnabled(name, app_manifest_path=app_manifest_path):
|
||||
|
||||
|
||||
_ENABLE_TIMING_TUNER = ProtoPirateDefineEnabled("ENABLE_TIMING_TUNER_SCENE")
|
||||
_ENABLE_EMULATE = ProtoPirateDefineEnabled("ENABLE_EMULATE_FEATURE")
|
||||
|
||||
_MAIN_APP_SOURCES = [
|
||||
"*.c*",
|
||||
"!protocols/plugins",
|
||||
"!scenes/plugins",
|
||||
"!protocols",
|
||||
"!protopirate_emulate_plugin.c",
|
||||
"!protopirate_psa_bf_plugin.c",
|
||||
"protocols/protocol_items.c",
|
||||
"protocols/protocols_common.c",
|
||||
"!raw_file_reader.c",
|
||||
@@ -30,6 +33,11 @@ if not _ENABLE_TIMING_TUNER:
|
||||
"!protocol_timings.c",
|
||||
]
|
||||
|
||||
if not _ENABLE_EMULATE:
|
||||
_MAIN_APP_SOURCES += [
|
||||
"!protopirate_scene_emulate.c",
|
||||
]
|
||||
|
||||
App(
|
||||
appid="proto_pirate",
|
||||
name="ProtoPirate",
|
||||
@@ -39,7 +47,7 @@ App(
|
||||
requires=["gui"],
|
||||
stack_size=8 * 1024,
|
||||
fap_description="Decode car key fob signals from Sub-GHz",
|
||||
fap_version="3.0",
|
||||
fap_version="3.2",
|
||||
fap_icon="images/protopirate_10px.png",
|
||||
fap_category="Sub-GHz",
|
||||
fap_icon_assets="images",
|
||||
@@ -165,7 +173,11 @@ def ProtoPirateTxProtocolPlugin(
|
||||
App=App,
|
||||
FlipperAppType=FlipperAppType,
|
||||
tx_plugin_sources=_TX_PLUGIN_SOURCES,
|
||||
enable_emulate=_ENABLE_EMULATE,
|
||||
):
|
||||
if not enable_emulate:
|
||||
return
|
||||
|
||||
App(
|
||||
appid=f"protopirate_tx_{key}_plugin",
|
||||
apptype=FlipperAppType.PLUGIN,
|
||||
@@ -370,17 +382,18 @@ if _ENABLE_TIMING_TUNER:
|
||||
fal_embedded=True,
|
||||
)
|
||||
|
||||
App(
|
||||
appid="protopirate_emulate_plugin",
|
||||
apptype=FlipperAppType.PLUGIN,
|
||||
entry_point="protopirate_emulate_plugin_ep",
|
||||
requires=["proto_pirate"],
|
||||
sources=[
|
||||
"scenes/plugins/protopirate_emulate_plugin.c",
|
||||
"protocols/protocol_items.c",
|
||||
],
|
||||
fal_embedded=True,
|
||||
)
|
||||
if _ENABLE_EMULATE:
|
||||
App(
|
||||
appid="protopirate_emulate_plugin",
|
||||
apptype=FlipperAppType.PLUGIN,
|
||||
entry_point="protopirate_emulate_plugin_ep",
|
||||
requires=["proto_pirate"],
|
||||
sources=[
|
||||
"scenes/plugins/protopirate_emulate_plugin.c",
|
||||
"protocols/protocol_items.c",
|
||||
],
|
||||
fal_embedded=True,
|
||||
)
|
||||
|
||||
App(
|
||||
appid="protopirate_psa_bf_plugin",
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
#include <string.h>
|
||||
|
||||
#define TAG "ProtoPirateProtocolPlugin"
|
||||
#ifdef ENABLE_EMULATE_FEATURE
|
||||
#define PROTOPIRATE_TX_PLUGIN_PATH_MAX 160U
|
||||
#endif
|
||||
|
||||
static const char* protopirate_get_registry_plugin_path(ProtoPirateProtocolRegistryRoute route) {
|
||||
switch(route) {
|
||||
@@ -25,6 +27,7 @@ static const char* protopirate_get_registry_plugin_path(ProtoPirateProtocolRegis
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef ENABLE_EMULATE_FEATURE
|
||||
static bool protopirate_build_tx_protocol_plugin_path(
|
||||
const char* tx_key,
|
||||
char* plugin_path,
|
||||
@@ -40,6 +43,7 @@ static bool protopirate_build_tx_protocol_plugin_path(
|
||||
tx_key);
|
||||
return (written > 0) && ((size_t)written < plugin_path_size);
|
||||
}
|
||||
#endif
|
||||
|
||||
static const SubGhzProtocolRegistry protopirate_empty_protocol_registry = {
|
||||
.items = NULL,
|
||||
@@ -165,6 +169,7 @@ static bool protopirate_ensure_protocol_registry_plugin(
|
||||
return true;
|
||||
}
|
||||
|
||||
#ifdef ENABLE_EMULATE_FEATURE
|
||||
static bool protopirate_ensure_tx_protocol_plugin(
|
||||
ProtoPirateApp* app,
|
||||
const char* protocol_name,
|
||||
@@ -264,6 +269,7 @@ static bool protopirate_ensure_tx_protocol_plugin(
|
||||
*registry = plugin->registry;
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
|
||||
bool protopirate_refresh_protocol_registry(ProtoPirateApp* app, bool ensure_receiver_ready) {
|
||||
furi_check(app);
|
||||
@@ -378,6 +384,7 @@ bool protopirate_apply_protocol_registry_for_context(
|
||||
}
|
||||
|
||||
if(protocol_name && protocol_name[0] != '\0') {
|
||||
#ifdef ENABLE_EMULATE_FEATURE
|
||||
const char* registry_name = protopirate_protocol_catalog_canonical_name(protocol_name);
|
||||
if(!registry_name || !protopirate_protocol_catalog_can_tx(protocol_name)) {
|
||||
FURI_LOG_E(TAG, "No TX protocol plugin for %s", protocol_name);
|
||||
@@ -410,6 +417,9 @@ bool protopirate_apply_protocol_registry_for_context(
|
||||
subghz_environment_set_protocol_registry(app->txrx->environment, tx_registry);
|
||||
app->txrx->protocol_registry = tx_registry;
|
||||
return true;
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
ProtoPirateProtocolRegistryRoute route = protopirate_get_protocol_registry_route(
|
||||
|
||||
@@ -103,12 +103,14 @@ void protopirate_settings_load(ProtoPirateSettings* settings) {
|
||||
}
|
||||
settings->hopping_enabled = (hopping_temp == 1);
|
||||
|
||||
#ifdef ENABLE_EMULATE_FEATURE
|
||||
uint32_t emulate_temp = 0;
|
||||
if(!flipper_format_read_uint32(ff, "EmulateFeature", &emulate_temp, 1)) {
|
||||
FURI_LOG_I(TAG, "EmulateFeature key missing, defaulting to disabled");
|
||||
emulate_temp = 0;
|
||||
}
|
||||
settings->emulate_feature_enabled = (emulate_temp == 1);
|
||||
#endif
|
||||
|
||||
uint32_t check_saved_temp = 0;
|
||||
if(!flipper_format_read_uint32(ff, "CheckSaved", &check_saved_temp, 1)) {
|
||||
@@ -185,11 +187,13 @@ void protopirate_settings_save(ProtoPirateSettings* settings) {
|
||||
break;
|
||||
}
|
||||
|
||||
#ifdef ENABLE_EMULATE_FEATURE
|
||||
uint32_t emulate_temp = settings->emulate_feature_enabled ? 1 : 0;
|
||||
if(!flipper_format_write_uint32(ff, "EmulateFeature", &emulate_temp, 1)) {
|
||||
FURI_LOG_E(TAG, "Failed to write emulate feature flag");
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
|
||||
uint32_t check_saved_temp = settings->check_saved ? 1 : 0;
|
||||
if(!flipper_format_write_uint32(ff, "CheckSaved", &check_saved_temp, 1)) {
|
||||
|
||||
@@ -513,6 +513,20 @@ static bool protopirate_storage_copy_key_2(
|
||||
protopirate_storage_copy_u32_optional(save_file, flipper_format, "Key_2");
|
||||
}
|
||||
|
||||
static bool protopirate_storage_copy_key2(
|
||||
FlipperFormat* save_file,
|
||||
FlipperFormat* flipper_format) {
|
||||
bool copied = false;
|
||||
if(!protopirate_storage_copy_hex_fixed(save_file, flipper_format, "Key2", 8, &copied)) {
|
||||
return false;
|
||||
}
|
||||
if(copied) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return protopirate_storage_copy_hex_or_u32(save_file, flipper_format, "Key2", 4);
|
||||
}
|
||||
|
||||
static bool protopirate_storage_write_capture_data(
|
||||
FlipperFormat* save_file,
|
||||
FlipperFormat* flipper_format) {
|
||||
@@ -548,7 +562,7 @@ static bool protopirate_storage_write_capture_data(
|
||||
protopirate_storage_base_u32_fields,
|
||||
COUNT_OF(protopirate_storage_base_u32_fields)))
|
||||
break;
|
||||
if(!protopirate_storage_copy_hex_or_u32(save_file, flipper_format, "Key2", 4)) break;
|
||||
if(!protopirate_storage_copy_key2(save_file, flipper_format)) break;
|
||||
if(!protopirate_storage_copy_u32_optional(save_file, flipper_format, "KeyIdx")) break;
|
||||
if(!protopirate_storage_copy_u32_optional(save_file, flipper_format, "Seed")) break;
|
||||
if(!protopirate_storage_copy_hex_or_u32(save_file, flipper_format, "ValidationField", 2))
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
#include <furi.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "../defines.h"
|
||||
|
||||
#define TAG "ProtoPirateCatalog"
|
||||
|
||||
#define PROTOPIRATE_CC1101_REG_MDMCFG2 0x12U
|
||||
@@ -17,6 +19,12 @@
|
||||
|
||||
#define PROTOPIRATE_COUNT_OF(array) (sizeof(array) / sizeof((array)[0]))
|
||||
|
||||
#ifdef ENABLE_EMULATE_FEATURE
|
||||
#define PROTOPIRATE_TX_KEY(key) key
|
||||
#else
|
||||
#define PROTOPIRATE_TX_KEY(key) NULL
|
||||
#endif
|
||||
|
||||
typedef enum {
|
||||
ProtoPirateProtocolCatalogModulationAM = 0,
|
||||
ProtoPirateProtocolCatalogModulationFM,
|
||||
@@ -28,33 +36,33 @@ typedef struct {
|
||||
} ProtoPirateProtocolCatalogAlias;
|
||||
|
||||
static const ProtoPirateProtocolCatalogEntry protopirate_protocol_catalog[] = {
|
||||
{"Chrysler V0", ProtoPirateProtocolCatalogRouteAMDefault, "chrysler_v0"},
|
||||
{"Fiat V0", ProtoPirateProtocolCatalogRouteAMDefault, "fiat_v0"},
|
||||
{"Fiat V1", ProtoPirateProtocolCatalogRouteAMDefault, "fiat_v1"},
|
||||
{"Chrysler V0", ProtoPirateProtocolCatalogRouteAMDefault, PROTOPIRATE_TX_KEY("chrysler_v0")},
|
||||
{"Fiat V0", ProtoPirateProtocolCatalogRouteAMDefault, PROTOPIRATE_TX_KEY("fiat_v0")},
|
||||
{"Fiat V1", ProtoPirateProtocolCatalogRouteAMDefault, PROTOPIRATE_TX_KEY("fiat_v1")},
|
||||
{"Fiat V2", ProtoPirateProtocolCatalogRouteAMDefault, NULL},
|
||||
{"Ford V0", ProtoPirateProtocolCatalogRouteAMDefault, "ford_v0"},
|
||||
{"Ford V1", ProtoPirateProtocolCatalogRouteFMF4, "ford_v1"},
|
||||
{"Ford V2", ProtoPirateProtocolCatalogRouteFMF4, "ford_v2"},
|
||||
{"Ford V0", ProtoPirateProtocolCatalogRouteAMDefault, PROTOPIRATE_TX_KEY("ford_v0")},
|
||||
{"Ford V1", ProtoPirateProtocolCatalogRouteFMF4, PROTOPIRATE_TX_KEY("ford_v1")},
|
||||
{"Ford V2", ProtoPirateProtocolCatalogRouteFMF4, PROTOPIRATE_TX_KEY("ford_v2")},
|
||||
{"Ford V3", ProtoPirateProtocolCatalogRouteFMF4, NULL},
|
||||
{"Honda Static", ProtoPirateProtocolCatalogRouteFMHonda1, "honda_static"},
|
||||
{"Honda V1", ProtoPirateProtocolCatalogRouteAMDefault, "honda_v1"},
|
||||
{"Kia V0", ProtoPirateProtocolCatalogRouteFMDefault, "kia_v0"},
|
||||
{"Kia V1", ProtoPirateProtocolCatalogRouteAMDefault, "kia_v1"},
|
||||
{"Kia V2", ProtoPirateProtocolCatalogRouteAMDefault, "kia_v2"},
|
||||
{"Kia V3/V4", ProtoPirateProtocolCatalogRouteFMDefault, "kia_v3_v4"},
|
||||
{"Kia V5", ProtoPirateProtocolCatalogRouteFMDefault, "kia_v5"},
|
||||
{"Kia V6", ProtoPirateProtocolCatalogRouteFMDefault, "kia_v6"},
|
||||
{"Kia V7", ProtoPirateProtocolCatalogRouteFMDefault, "kia_v7"},
|
||||
{"Honda V2", ProtoPirateProtocolCatalogRouteFMF4, "honda_v2"},
|
||||
{"Mazda V0", ProtoPirateProtocolCatalogRouteByModulation, "mazda_v0"},
|
||||
{"Honda Static", ProtoPirateProtocolCatalogRouteFMHonda1, PROTOPIRATE_TX_KEY("honda_static")},
|
||||
{"Honda V1", ProtoPirateProtocolCatalogRouteAMDefault, PROTOPIRATE_TX_KEY("honda_v1")},
|
||||
{"Kia V0", ProtoPirateProtocolCatalogRouteFMDefault, PROTOPIRATE_TX_KEY("kia_v0")},
|
||||
{"Kia V1", ProtoPirateProtocolCatalogRouteAMDefault, PROTOPIRATE_TX_KEY("kia_v1")},
|
||||
{"Kia V2", ProtoPirateProtocolCatalogRouteAMDefault, PROTOPIRATE_TX_KEY("kia_v2")},
|
||||
{"Kia V3/V4", ProtoPirateProtocolCatalogRouteFMDefault, PROTOPIRATE_TX_KEY("kia_v3_v4")},
|
||||
{"Kia V5", ProtoPirateProtocolCatalogRouteFMDefault, PROTOPIRATE_TX_KEY("kia_v5")},
|
||||
{"Kia V6", ProtoPirateProtocolCatalogRouteFMDefault, PROTOPIRATE_TX_KEY("kia_v6")},
|
||||
{"Kia V7", ProtoPirateProtocolCatalogRouteFMDefault, PROTOPIRATE_TX_KEY("kia_v7")},
|
||||
{"Honda V2", ProtoPirateProtocolCatalogRouteFMF4, PROTOPIRATE_TX_KEY("honda_v2")},
|
||||
{"Mazda V0", ProtoPirateProtocolCatalogRouteByModulation, PROTOPIRATE_TX_KEY("mazda_v0")},
|
||||
{"Mitsubishi V0", ProtoPirateProtocolCatalogRouteFMDefault, NULL},
|
||||
{"Porsche Touareg", ProtoPirateProtocolCatalogRouteAMDefault, NULL},
|
||||
{"PSA", ProtoPirateProtocolCatalogRouteByModulation, "psa"},
|
||||
{"Renault V0", ProtoPirateProtocolCatalogRouteAMDefault, "renault_v0"},
|
||||
{"PSA", ProtoPirateProtocolCatalogRouteByModulation, PROTOPIRATE_TX_KEY("psa")},
|
||||
{"Renault V0", ProtoPirateProtocolCatalogRouteAMDefault, PROTOPIRATE_TX_KEY("renault_v0")},
|
||||
{"Scher-Khan", ProtoPirateProtocolCatalogRouteFMDefault, NULL},
|
||||
{"Star Line", ProtoPirateProtocolCatalogRouteAMDefault, "star_line"},
|
||||
{"Subaru", ProtoPirateProtocolCatalogRouteAMDefault, "subaru"},
|
||||
{"VAG", ProtoPirateProtocolCatalogRouteAMVag, "vag"},
|
||||
{"Star Line", ProtoPirateProtocolCatalogRouteAMDefault, PROTOPIRATE_TX_KEY("star_line")},
|
||||
{"Subaru", ProtoPirateProtocolCatalogRouteAMDefault, PROTOPIRATE_TX_KEY("subaru")},
|
||||
{"VAG", ProtoPirateProtocolCatalogRouteAMVag, PROTOPIRATE_TX_KEY("vag")},
|
||||
};
|
||||
|
||||
static const ProtoPirateProtocolCatalogAlias protopirate_protocol_catalog_aliases[] = {
|
||||
|
||||
@@ -90,7 +90,11 @@ ProtoPirateApp* protopirate_app_alloc() {
|
||||
app->auto_save = settings.auto_save;
|
||||
app->check_saved = settings.check_saved;
|
||||
app->tx_power = settings.tx_power;
|
||||
#ifdef ENABLE_EMULATE_FEATURE
|
||||
app->emulate_feature_enabled = settings.emulate_feature_enabled;
|
||||
#else
|
||||
app->emulate_feature_enabled = false;
|
||||
#endif
|
||||
|
||||
// Init setting - KEEP THIS, it's small
|
||||
app->setting = subghz_setting_alloc();
|
||||
@@ -175,7 +179,11 @@ void protopirate_app_free(ProtoPirateApp* app) {
|
||||
settings.check_saved = app->check_saved;
|
||||
settings.tx_power = app->tx_power;
|
||||
settings.hopping_enabled = (app->txrx->hopper_state != ProtoPirateHopperStateOFF);
|
||||
#ifdef ENABLE_EMULATE_FEATURE
|
||||
settings.emulate_feature_enabled = app->emulate_feature_enabled;
|
||||
#else
|
||||
settings.emulate_feature_enabled = false;
|
||||
#endif
|
||||
|
||||
// Find current preset index
|
||||
settings.preset_index = 0;
|
||||
@@ -273,14 +281,18 @@ int32_t protopirate_app(char* p) {
|
||||
|
||||
//We now jump straight to emulate scene from Browser. If the user wanted the key to look at, just click back.
|
||||
if(load_saved) {
|
||||
#ifdef ENABLE_EMULATE_FEATURE
|
||||
if(protopirate_app->emulate_feature_enabled) {
|
||||
view_dispatcher_send_custom_event(
|
||||
protopirate_app->view_dispatcher, ProtoPirateCustomEventSavedInfoEmulate);
|
||||
notification_message(protopirate_app->notifications, &sequence_success);
|
||||
} else {
|
||||
#endif
|
||||
view_dispatcher_send_custom_event(
|
||||
protopirate_app->view_dispatcher, ProtoPirateCustomEventReceiverInfoSave);
|
||||
#ifdef ENABLE_EMULATE_FEATURE
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
view_dispatcher_run(protopirate_app->view_dispatcher);
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
#define CREDIT_LINE_HEIGHT 10
|
||||
#define SCROLL_SPEED 1
|
||||
|
||||
#ifdef ENABLE_EMULATE_FEATURE
|
||||
static const InputKey EMULATE_TOGGLE_COMBO[] = {
|
||||
InputKeyUp,
|
||||
InputKeyUp,
|
||||
@@ -23,6 +24,7 @@ static const InputKey EMULATE_TOGGLE_COMBO[] = {
|
||||
InputKeyRight,
|
||||
};
|
||||
#define EMULATE_TOGGLE_COMBO_LEN (sizeof(EMULATE_TOGGLE_COMBO) / sizeof(EMULATE_TOGGLE_COMBO[0]))
|
||||
#endif
|
||||
|
||||
static const char* credits[] = {
|
||||
"",
|
||||
@@ -62,7 +64,9 @@ typedef struct {
|
||||
uint8_t frame;
|
||||
uint8_t seed;
|
||||
int16_t scroll_offset;
|
||||
#ifdef ENABLE_EMULATE_FEATURE
|
||||
uint8_t combo_progress;
|
||||
#endif
|
||||
} GlitchState;
|
||||
|
||||
static GlitchState g_state = {0};
|
||||
@@ -159,6 +163,7 @@ static void about_draw_callback(Canvas* canvas, void* context) {
|
||||
|
||||
static bool about_input_callback(InputEvent* event, void* context) {
|
||||
furi_check(context);
|
||||
#ifdef ENABLE_EMULATE_FEATURE
|
||||
ProtoPirateApp* app = context;
|
||||
|
||||
if(event->type != InputTypePress) {
|
||||
@@ -184,8 +189,13 @@ static bool about_input_callback(InputEvent* event, void* context) {
|
||||
}
|
||||
|
||||
return true;
|
||||
#else
|
||||
UNUSED(event);
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef ENABLE_EMULATE_FEATURE
|
||||
static void about_show_emulate_toggle_popup(ProtoPirateApp* app) {
|
||||
const bool now_enabled = app->emulate_feature_enabled;
|
||||
|
||||
@@ -204,6 +214,7 @@ static void about_show_emulate_toggle_popup(ProtoPirateApp* app) {
|
||||
dialog_message_show(app->dialogs, message);
|
||||
dialog_message_free(message);
|
||||
}
|
||||
#endif
|
||||
|
||||
void protopirate_scene_about_on_enter(void* context) {
|
||||
furi_check(context);
|
||||
@@ -218,7 +229,9 @@ void protopirate_scene_about_on_enter(void* context) {
|
||||
g_state.frame = 0;
|
||||
g_state.seed = furi_get_tick() & 0xFF;
|
||||
g_state.scroll_offset = 0;
|
||||
#ifdef ENABLE_EMULATE_FEATURE
|
||||
g_state.combo_progress = 0;
|
||||
#endif
|
||||
|
||||
view_set_draw_callback(app->view_about, about_draw_callback);
|
||||
view_set_input_callback(app->view_about, about_input_callback);
|
||||
@@ -245,7 +258,9 @@ bool protopirate_scene_about_on_event(void* context, SceneManagerEvent event) {
|
||||
|
||||
view_commit_model(app->view_about, true);
|
||||
consumed = true;
|
||||
} else if(event.type == SceneManagerEventTypeCustom) {
|
||||
}
|
||||
#ifdef ENABLE_EMULATE_FEATURE
|
||||
else if(event.type == SceneManagerEventTypeCustom) {
|
||||
if(event.event == ProtoPirateCustomEventAboutToggleEmulate) {
|
||||
app->emulate_feature_enabled = !app->emulate_feature_enabled;
|
||||
|
||||
@@ -262,6 +277,7 @@ bool protopirate_scene_about_on_event(void* context, SceneManagerEvent event) {
|
||||
consumed = true;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
return consumed;
|
||||
}
|
||||
|
||||
@@ -1205,7 +1205,9 @@ bool protopirate_scene_sub_decode_on_event(void* context, SceneManagerEvent even
|
||||
app);
|
||||
|
||||
ctx->signal_info_left_is_emulate = false;
|
||||
#ifdef ENABLE_EMULATE_FEATURE
|
||||
bool left_button_used = false;
|
||||
#endif
|
||||
app->emulate_disabled_for_loaded = true;
|
||||
|
||||
// Store reference to history item's flipper format for saving
|
||||
@@ -1239,7 +1241,9 @@ bool protopirate_scene_sub_decode_on_event(void* context, SceneManagerEvent even
|
||||
"Brute force",
|
||||
protopirate_scene_sub_decode_widget_callback,
|
||||
app);
|
||||
#ifdef ENABLE_EMULATE_FEATURE
|
||||
left_button_used = true;
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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 |
@@ -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:
|
||||
- **Furrtek’s 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);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user