Files
ProtoPirate/scenes/plugins/protopirate_emulate_plugin.c
0mega 62b31bfe95 More Memory optimizations and new protocols
- Split RX registries into more plugins: AM, AM VAG, FM, FM F4, and FM Honda1
- Add per-protocol TX plugins so emulation loads only the selected encoder
- Sub Decode (enabled by default) and Timing Tuner as plugins
- Max history increased to 20 signals
- Fix Sub Decode and simplified UI
- Add Check Saved setting ported from dexter_pester PR !
- Fix Fiat V1 decoder and add HITAG2 key TX support
- Add Renault V0, Fiat V2, Honda V2, Ford V3 (& US variant) Thanks Ash

Sorry this is a lot at once x)
2026-06-30 21:28:26 +02:00

1459 lines
50 KiB
C

// scenes/plugins/protopirate_emulate_plugin.c
#include "protopirate_emulate_plugin.h"
#include "../../protopirate_app_i.h"
#ifdef ENABLE_EMULATE_FEATURE
#include "../../protocols/protocols_common.h"
#include "../../protocols/protocol_items.h"
#include "../../protocols/fiat_v1.h"
#include "../../protocols/ford_v1.h"
#include "../../protocols/kia_v0.h"
#include "../../protocols/kia_v3_v4.h"
#include "../../protocols/kia_v7.h"
#include "../../protocols/psa.h"
#include "../../protocols/renault_v0.h"
#include <input/input.h>
#include <gui/canvas.h>
#include <gui/view.h>
#include <gui/view_dispatcher.h>
#include <gui/scene_manager.h>
#include <storage/storage.h>
#include <flipper_format/flipper_format.h>
#include <lib/subghz/devices/devices.h>
#include <lib/subghz/transmitter.h>
#include <lib/subghz/subghz_setting.h>
#include <notification/notification_messages.h>
#include <furi.h>
#include <string.h>
#define TAG "ProtoPirateEmulatePlugin"
#define MIN_TX_TIME 666U
#define MIN_TX_TIME_KIA_V3_V4 1600U
#define TX_PRESET_PATCH_MAX_SIZE 128U
#define EMU_PRESET_KEY_PROTOCOL "Protocol"
#define EMU_PRESET_KEY_FREQUENCY "Frequency"
#define EMU_PRESET_KEY_PRESET "Preset"
#define EMU_PRESET_KEY_SERIAL "Serial"
#define EMU_PRESET_KEY_BTN "Btn"
#define EMU_PRESET_KEY_CNT "Cnt"
#define EMU_PRESET_KEY_TYPE "Type"
#define EMU_PRESET_KEY_HITAG2_KEY "Hitag2 Key"
#define EMU_PRESET_KEY_HITAG2_EPOCH "Hitag2 Epoch"
#define EMU_CUSTOM_PRESET_KEY "Custom_preset_data"
#define EMU_FIAT_V1_KEY_TEXT_LEN 12U
typedef struct {
uint32_t original_counter;
uint32_t current_counter;
uint32_t serial;
uint8_t original_button;
FuriString* protocol_name;
const char* preset;
FuriString* preset_from_file;
uint32_t freq;
FlipperFormat* flipper_format;
SubGhzTransmitter* transmitter;
bool is_transmitting;
bool flag_stop_called;
bool replay_only;
Storage* storage;
char hitag2_key_text[EMU_FIAT_V1_KEY_TEXT_LEN + 1U];
} EmulateContext;
typedef struct {
uint8_t* data;
size_t size;
bool should_free;
} EmulateResolvedPreset;
static EmulateContext* emulate_context = NULL;
static const ProtoPirateEmulateHostApi* g_host_api = NULL;
static bool emulate_hex_nibble(char c, uint8_t* nibble) {
if(c >= '0' && c <= '9') {
*nibble = (uint8_t)(c - '0');
return true;
}
if(c >= 'A' && c <= 'F') {
*nibble = (uint8_t)(c - 'A' + 10);
return true;
}
if(c >= 'a' && c <= 'f') {
*nibble = (uint8_t)(c - 'a' + 10);
return true;
}
return false;
}
static bool emulate_parse_hitag2_key_text(const char* text, uint8_t key[6]) {
if(!text || !key) return false;
uint8_t hex_count = 0U;
uint8_t high_nibble = 0U;
for(size_t i = 0U; text[i] != '\0'; i++) {
if(text[i] == ' ') continue;
uint8_t nibble = 0U;
if(!emulate_hex_nibble(text[i], &nibble) || hex_count >= EMU_FIAT_V1_KEY_TEXT_LEN) {
return false;
}
if((hex_count & 1U) == 0U) {
high_nibble = nibble;
} else {
key[hex_count >> 1U] = (uint8_t)((high_nibble << 4U) | nibble);
}
hex_count++;
}
return hex_count == EMU_FIAT_V1_KEY_TEXT_LEN;
}
static bool emulate_has_hitag2_key(FlipperFormat* flipper_format) {
if(!flipper_format) return false;
uint8_t key[6] = {0};
flipper_format_rewind(flipper_format);
return flipper_format_read_hex(flipper_format, EMU_PRESET_KEY_HITAG2_KEY, key, sizeof(key));
}
static void emulate_request_nav_pop(ProtoPirateApp* app) {
app->emulate_nav_pending = EMULATE_NAV_POP;
}
static void emulate_request_nav_after_exit(ProtoPirateApp* app) {
if(scene_manager_has_previous_scene(app->scene_manager, ProtoPirateSceneStart)) {
app->emulate_nav_pending = EMULATE_NAV_POP;
} else {
app->emulate_nav_pending = EMULATE_NAV_STOP_APP;
}
}
static bool emulate_prompt_fiat_v1_key(ProtoPirateApp* app, EmulateContext* ctx);
static void emulate_fiat_v1_key_input_callback(void* context) {
ProtoPirateApp* app = context;
EmulateContext* ctx = emulate_context;
uint8_t key[6] = {0};
if(!app || !ctx || !ctx->flipper_format ||
!emulate_parse_hitag2_key_text(ctx->hitag2_key_text, key)) {
if(app && app->notifications) {
notification_message(app->notifications, &sequence_error);
}
if(app && ctx) {
(void)emulate_prompt_fiat_v1_key(app, ctx);
}
return;
}
flipper_format_rewind(ctx->flipper_format);
if(!flipper_format_insert_or_update_hex(
ctx->flipper_format, EMU_PRESET_KEY_HITAG2_KEY, key, sizeof(key))) {
notification_message(app->notifications, &sequence_error);
emulate_request_nav_pop(app);
return;
}
uint32_t epoch = 0U;
flipper_format_rewind(ctx->flipper_format);
if(!flipper_format_read_uint32(ctx->flipper_format, EMU_PRESET_KEY_HITAG2_EPOCH, &epoch, 1U)) {
flipper_format_rewind(ctx->flipper_format);
flipper_format_insert_or_update_uint32(
ctx->flipper_format, EMU_PRESET_KEY_HITAG2_EPOCH, &epoch, 1U);
}
view_dispatcher_switch_to_view(app->view_dispatcher, ProtoPirateViewAbout);
if(app->view_about) {
view_commit_model(app->view_about, true);
}
}
static bool emulate_prompt_fiat_v1_key(ProtoPirateApp* app, EmulateContext* ctx) {
furi_check(app);
furi_check(ctx);
if(!g_host_api || !g_host_api->ensure_text_input || !g_host_api->ensure_text_input(app)) {
return false;
}
memset(ctx->hitag2_key_text, 0, sizeof(ctx->hitag2_key_text));
text_input_reset(app->text_input);
text_input_set_header_text(app->text_input, "HITAG2 key (12 hex):");
text_input_set_result_callback(
app->text_input,
emulate_fiat_v1_key_input_callback,
app,
ctx->hitag2_key_text,
sizeof(ctx->hitag2_key_text),
true);
view_dispatcher_switch_to_view(app->view_dispatcher, ProtoPirateViewTextInput);
return true;
}
static bool emu_preset_name_is_custom_marker(const char* preset_name) {
return preset_name && (!strcmp(preset_name, "Custom") || !strcmp(preset_name, "CUSTOM") ||
!strcmp(preset_name, "FuriHalSubGhzPresetCustom") ||
strstr(preset_name, "PresetCustom"));
}
static const char* emu_get_short_preset_name(const char* preset_name) {
if(!preset_name || preset_name[0] == '\0') return "AM650";
if(strstr(preset_name, "Ook650") || strstr(preset_name, "OOK650")) return "AM650";
if(strstr(preset_name, "Ook270") || strstr(preset_name, "OOK270")) return "AM270";
if(strstr(preset_name, "2FSKDev238") || strstr(preset_name, "Dev238")) return "FM238";
if(strstr(preset_name, "2FSKDev12K") || strstr(preset_name, "Dev12K")) return "FM12K";
if(strstr(preset_name, "2FSKDev476") || strstr(preset_name, "Dev476")) return "FM476";
if(emu_preset_name_is_custom_marker(preset_name)) return "Custom";
if(!strcmp(preset_name, "AM650")) return "AM650";
if(!strcmp(preset_name, "AM270")) return "AM270";
if(!strcmp(preset_name, "FM238")) return "FM238";
if(!strcmp(preset_name, "FM12K")) return "FM12K";
if(!strcmp(preset_name, "FM476")) return "FM476";
return preset_name;
}
// -----------------------------------------------------------------------------
// Notification sequence used while transmitting. Duplicated locally so the
// plugin does not link against the host's `static const` copy.
// -----------------------------------------------------------------------------
static const NotificationSequence emu_sequence_tx = {
&message_note_c5,
&message_vibro_on,
&message_red_255,
&message_blue_255,
&message_blink_start_10,
&message_delay_25,
&message_vibro_off,
&message_delay_25,
&message_sound_off,
NULL,
};
#define TX_PRESET_VALUES_AM 8 // Gets 1 added, so is 1 less than actual value.
#define TX_PRESET_VALUES_COUNT 17
static const uint8_t tx_power_value[TX_PRESET_VALUES_COUNT] = {
// FM Power Values for 1st PA Table Byte.
0,
0xC0, // 10dBm
0xC8, // 7dBm
0x84, // 5dBm
0x60, // 0dBm
0x34, // -10dBm
0x1D, // -15dBm
0x0E, // -20dBm
0x12, // -30dBm
// AM Power Values for 1st PA Table Byte.
0xC0, // 12dBm
0xCD, // 7dBm
0x86, // 5dBm
0x50, // 0dBm
0x26, // -10dBm
0x1D, // -15dBm
0x17, // -20dBm
0x03 // -30dBm
};
static bool emulate_radio_ready(ProtoPirateApp* app) {
furi_check(app);
return app->radio_initialized && app->txrx && app->txrx->radio_device &&
app->txrx->environment;
}
static uint32_t emulate_min_tx_time(const EmulateContext* ctx) {
if(!ctx || !ctx->protocol_name) return MIN_TX_TIME;
const char* proto = furi_string_get_cstr(ctx->protocol_name);
proto = protopirate_protocol_catalog_canonical_name(proto);
if(proto && strcmp(proto, KIA_PROTOCOL_V3_V4_NAME) == 0) {
return MIN_TX_TIME_KIA_V3_V4;
}
return MIN_TX_TIME;
}
static void emulate_context_reset_transmitter(void) {
EmulateContext* ctx = emulate_context;
if(ctx && ctx->transmitter) {
subghz_transmitter_free(ctx->transmitter);
ctx->transmitter = NULL;
}
}
static void emulate_resolved_preset_release(EmulateResolvedPreset* preset) {
if(!preset) return;
if(preset->should_free && preset->data) free(preset->data);
preset->data = NULL;
preset->size = 0U;
preset->should_free = false;
}
static bool emulate_resolved_preset_assign_named(
ProtoPirateApp* app,
const char* preset_name,
EmulateResolvedPreset* preset) {
furi_check(app);
furi_check(preset);
int preset_index = subghz_setting_get_inx_preset_by_name(app->setting, preset_name);
if(preset_index < 0) return false;
uint8_t* preset_data = subghz_setting_get_preset_data(app->setting, (size_t)preset_index);
size_t preset_size = subghz_setting_get_preset_data_size(app->setting, (size_t)preset_index);
if(!preset_data || !preset_size) return false;
preset->data = preset_data;
preset->size = preset_size;
preset->should_free = false;
return true;
}
static bool
emulate_resolved_preset_try_load_custom(EmulateContext* ctx, EmulateResolvedPreset* preset) {
furi_check(ctx);
furi_check(preset);
if(!ctx->flipper_format) return false;
uint32_t value_count = 0;
flipper_format_rewind(ctx->flipper_format);
if(!flipper_format_get_value_count(ctx->flipper_format, EMU_CUSTOM_PRESET_KEY, &value_count) ||
value_count == 0U || value_count >= 1024U) {
return false;
}
uint8_t* preset_data = malloc(value_count);
if(!preset_data) return false;
flipper_format_rewind(ctx->flipper_format);
if(!flipper_format_read_hex(
ctx->flipper_format, EMU_CUSTOM_PRESET_KEY, preset_data, value_count)) {
free(preset_data);
return false;
}
preset->data = preset_data;
preset->size = value_count;
preset->should_free = true;
return true;
}
static bool emulate_context_resolve_tx_preset(
ProtoPirateApp* app,
EmulateContext* ctx,
EmulateResolvedPreset* preset) {
furi_check(app);
furi_check(ctx);
furi_check(preset);
memset(preset, 0, sizeof(*preset));
const char* requested_preset = ctx->preset ? ctx->preset : "AM650";
const char* saved_preset =
ctx->preset_from_file ? furi_string_get_cstr(ctx->preset_from_file) : NULL;
if(emu_preset_name_is_custom_marker(requested_preset)) {
if(emulate_resolved_preset_try_load_custom(ctx, preset)) {
ctx->preset = "Custom";
return true;
}
FURI_LOG_W(TAG, "Custom preset data missing");
if(saved_preset && saved_preset[0] && !emu_preset_name_is_custom_marker(saved_preset)) {
requested_preset = saved_preset;
} else {
requested_preset = "AM650";
}
}
if(emulate_resolved_preset_assign_named(app, requested_preset, preset)) {
ctx->preset = requested_preset;
return true;
}
if(saved_preset && saved_preset[0] && strcmp(saved_preset, requested_preset) != 0 &&
!emu_preset_name_is_custom_marker(saved_preset) &&
emulate_resolved_preset_assign_named(app, saved_preset, preset)) {
ctx->preset = saved_preset;
return true;
}
if(strcmp(requested_preset, "AM650") != 0) {
FURI_LOG_W(TAG, "Preset %s not found, trying AM650", requested_preset);
if(emulate_resolved_preset_assign_named(app, "AM650", preset)) {
ctx->preset = "AM650";
return true;
}
}
FURI_LOG_W(TAG, "AM650 not found, trying FM476");
if(emulate_resolved_preset_assign_named(app, "FM476", preset)) {
ctx->preset = "FM476";
return true;
}
return false;
}
static void emu_stop_tx(ProtoPirateApp* app) {
FURI_LOG_I(TAG, "Stopping transmission");
if(app->txrx->radio_device) {
subghz_devices_stop_async_tx(app->txrx->radio_device);
} else {
FURI_LOG_W(TAG, "stop_tx requested without radio device");
}
if(emulate_context && emulate_context->transmitter) {
subghz_transmitter_stop(emulate_context->transmitter);
}
furi_delay_ms(10);
if(app->txrx->radio_device) {
subghz_devices_idle(app->txrx->radio_device);
}
app->txrx->txrx_state = ProtoPirateTxRxStateIDLE;
app->start_tx_time = 0;
emulate_context_reset_transmitter();
FURI_LOG_I(TAG, "Transmission stopped, state set to IDLE");
notification_message(app->notifications, &sequence_blink_stop);
}
static void emulate_context_free(void) {
if(emulate_context == NULL) return;
if(emulate_context->transmitter) {
subghz_transmitter_free(emulate_context->transmitter);
emulate_context->transmitter = NULL;
}
if(emulate_context->flipper_format) {
flipper_format_free(emulate_context->flipper_format);
emulate_context->flipper_format = NULL;
}
if(emulate_context->protocol_name) {
furi_string_free(emulate_context->protocol_name);
emulate_context->protocol_name = NULL;
}
if(emulate_context->preset_from_file) {
furi_string_free(emulate_context->preset_from_file);
emulate_context->preset_from_file = NULL;
}
if(emulate_context->storage) {
furi_record_close(RECORD_STORAGE);
emulate_context->storage = NULL;
}
free(emulate_context);
emulate_context = NULL;
}
static bool emulate_context_try_init_transmitter(ProtoPirateApp* app, EmulateContext* ctx) {
if(ctx->transmitter) return true;
if(!ctx->flipper_format || !ctx->protocol_name) return false;
const char* proto_name = furi_string_get_cstr(ctx->protocol_name);
const char* registry_name = protopirate_protocol_catalog_canonical_name(proto_name);
if(!registry_name || !protopirate_protocol_catalog_can_tx(proto_name)) {
FURI_LOG_E(TAG, "Protocol %s has no TX catalog entry", proto_name ? proto_name : "?");
return false;
}
if(strcmp(proto_name, registry_name) != 0) {
FURI_LOG_I(TAG, "Protocol name %s mapped to %s for registry", proto_name, registry_name);
}
EmulateResolvedPreset resolved_preset;
if(!emulate_context_resolve_tx_preset(app, ctx, &resolved_preset)) {
FURI_LOG_E(TAG, "Failed to resolve preset data for emulate registry");
return false;
}
bool registry_ready = g_host_api && g_host_api->apply_protocol_registry_for_context &&
g_host_api->apply_protocol_registry_for_context(
app,
ctx->preset,
ctx->freq,
resolved_preset.data,
resolved_preset.size,
registry_name);
emulate_resolved_preset_release(&resolved_preset);
if(!registry_ready) {
FURI_LOG_E(TAG, "Failed to apply protocol registry for emulate preset");
return false;
}
const SubGhzProtocol* protocol = NULL;
const SubGhzProtocolRegistry* active_registry = app->txrx->protocol_registry;
if(!active_registry) {
FURI_LOG_E(TAG, "Active protocol registry unavailable");
return false;
}
for(size_t i = 0; i < active_registry->size; i++) {
if(strcmp(active_registry->items[i]->name, registry_name) == 0) {
protocol = active_registry->items[i];
FURI_LOG_I(TAG, "Found protocol %s in registry at index %zu", registry_name, i);
break;
}
}
if(!protocol || !protocol->encoder || !protocol->encoder->alloc) {
FURI_LOG_E(TAG, "Protocol %s has no encoder or not in registry", registry_name);
return false;
}
ctx->transmitter = subghz_transmitter_alloc_init(app->txrx->environment, registry_name);
if(!ctx->transmitter) {
FURI_LOG_E(TAG, "Failed to allocate transmitter for %s", registry_name);
return false;
}
flipper_format_rewind(ctx->flipper_format);
SubGhzProtocolStatus status =
subghz_transmitter_deserialize(ctx->transmitter, ctx->flipper_format);
if(status != SubGhzProtocolStatusOk) {
FURI_LOG_E(TAG, "Failed to deserialize transmitter, status: %d", status);
subghz_transmitter_free(ctx->transmitter);
ctx->transmitter = NULL;
return false;
}
FURI_LOG_I(TAG, "Transmitter ready (lazy init)");
return true;
}
static uint8_t emu_button_for_protocol(
const char* protocol,
InputKey key,
uint8_t original,
FlipperFormat* ff) {
if(!protocol) {
return original;
}
protocol = protopirate_protocol_catalog_canonical_name(protocol);
if(strcmp(protocol, KIA_PROTOCOL_V7_NAME) == 0) {
switch(key) {
case InputKeyUp:
return 0x01; // Lock
case InputKeyOk:
return 0x02; // Unlock
case InputKeyDown:
return 0x03; // Trunk
case InputKeyRight:
return 0x08; // Panic
default:
return original;
}
}
if(strcmp(protocol, KIA_PROTOCOL_V3_V4_NAME) == 0) {
switch(key) {
case InputKeyUp:
return 0x01;
case InputKeyOk:
return 0x02;
case InputKeyDown:
return 0x03;
case InputKeyLeft:
return 0x04;
case InputKeyRight:
return 0x08;
default:
return original;
}
}
if(strstr(protocol, "Kia")) {
uint32_t kia_v0_type = 1;
if(ff) {
flipper_format_rewind(ff);
flipper_format_read_uint32(ff, EMU_PRESET_KEY_TYPE, &kia_v0_type, 1);
}
if(kia_v0_type == 2) {
switch(key) {
case InputKeyUp:
return 0x3; // Lock
case InputKeyOk:
return 0x4; // Unlock
case InputKeyDown:
return 0x2; // Boot
case InputKeyLeft:
return 0x1; // Panic
default:
return original;
}
}
if(kia_v0_type == 3) {
switch(key) {
case InputKeyUp:
return 1;
case InputKeyOk:
return 2;
case InputKeyDown:
return 3;
case InputKeyLeft:
return 4;
case InputKeyRight:
return 5;
default:
return original;
}
}
switch(key) {
case InputKeyUp:
return 0x1; // Lock
case InputKeyOk:
return 0x2; // Unlock
case InputKeyDown:
return 0x3; // Boot
case InputKeyLeft:
return 0x4; // Panic
case InputKeyRight:
return 0x8; // Horn/Lights?
default:
return original;
}
} else if(strstr(protocol, "VAG")) {
if(original == 0x10 || original == 0x20 || original == 0x40) {
switch(key) {
case InputKeyUp:
return 0x20; // Lock
case InputKeyOk:
return 0x10; // Unlock
case InputKeyDown:
return 0x40; // Boot
default:
return original;
}
}
switch(key) {
case InputKeyUp:
return 0x2; // Lock
case InputKeyOk:
return 0x1; // Unlock
case InputKeyDown:
return 0x4; // Boot
case InputKeyLeft:
return 0x8; // Panic
case InputKeyRight:
return 0x3; // Un+Lk combo
default:
return original;
}
} else if(strstr(protocol, RENAULT_PROTOCOL_V0_NAME)) {
uint32_t renault_type = 0U;
if(ff) {
flipper_format_rewind(ff);
flipper_format_read_uint32(ff, EMU_PRESET_KEY_TYPE, &renault_type, 1);
}
const bool type_13 = renault_type == 0x13U;
const bool type_high = (renault_type == 0x1AU) || (renault_type == 0x3BU);
switch(key) {
case InputKeyUp:
return type_13 ? 0x06 : (type_high ? 0x44 : 0x04);
case InputKeyOk:
return type_13 ? 0x0A : (type_high ? 0x48 : 0x08);
default:
return original;
}
} else if(strstr(protocol, "Honda V1")) {
switch(key) {
case InputKeyUp:
return 0x08; // Lock
case InputKeyOk:
return 0x00; // Unlock
case InputKeyDown:
return 0x09; // Trunk
case InputKeyLeft:
return 0x0A; // Panic
default:
return original;
}
} else if(strstr(protocol, "Honda Static")) {
switch(key) {
case InputKeyUp:
return 0x1; // Lock
case InputKeyOk:
return 0x2; // Unlock
case InputKeyDown:
return 0x4; // Trunk
case InputKeyRight:
return 0x5; // Remote Start
case InputKeyLeft:
return 0x8; // Panic
default:
return original;
}
} else if(strstr(protocol, "Mazda")) {
switch(key) {
case InputKeyUp:
return 0x01; // Lock
case InputKeyOk:
return 0x02; // Unlock
case InputKeyDown:
return 0x04; // Trunk
case InputKeyRight:
return 0x08; // Remote
default:
return original;
}
} else if(strstr(protocol, "Honda V2")) {
switch(key) {
case InputKeyUp:
return 0x02; // Lock
case InputKeyOk:
return 0x04; // Unlock
default:
return original;
}
} else if(strcmp(protocol, PSA_PROTOCOL_NAME) == 0 || strstr(protocol, "PSA")) {
switch(key) {
case InputKeyUp:
return 0x1; // Lock
case InputKeyOk:
return 0x2; // Unlock
case InputKeyDown:
return 0x4; // Trunk
case InputKeyLeft:
return 0x8; // Panic
default:
return original;
}
} else if(strstr(protocol, "Ford")) {
if(strstr(protocol, FORD_PROTOCOL_V1_NAME)) {
switch(key) {
case InputKeyUp:
return 0x1; // Lock
case InputKeyOk:
return 0x2; // Unlock
case InputKeyDown:
return 0x4; // Trunk
case InputKeyLeft:
return 0x8; // Panic
default:
return original;
}
}
switch(key) {
case InputKeyLeft:
return 0x1; // Panic
case InputKeyUp:
return 0x2; // Lock
case InputKeyOk:
return 0x4; // Unlock
case InputKeyDown:
return 0x8; // Boot
case InputKeyRight:
return 0x10;
default:
return original;
}
} else if(strstr(protocol, "Subaru")) {
switch(key) {
case InputKeyUp:
return 0x1; // Lock?
case InputKeyOk:
return 0x2; // Unlock?
case InputKeyDown:
return 0x3; // Boot?
case InputKeyLeft:
return 0x4; // Panic?
case InputKeyRight:
return 0x8;
default:
return original;
}
} else if(strstr(protocol, "Chrysler")) {
switch(key) {
case InputKeyUp:
return 0x1; // Lock
case InputKeyOk:
return 0x2; // Unlock
default:
return original;
}
} else if(strstr(protocol, FIAT_V1_PROTOCOL_NAME)) {
switch(key) {
case InputKeyUp:
return 0x4; // Lock
case InputKeyOk:
return 0x8; // Unlock
case InputKeyDown:
return 0x2; // Trunk
case InputKeyLeft:
return 0x1; // Close
default:
return original;
}
} else if(strstr(protocol, "Fiat")) {
return original;
} else if(strstr(protocol, "Porsche")) {
return original;
} else if(strstr(protocol, "Scher")) {
return original;
} else if(strstr(protocol, "Star Line")) {
return original;
}
return original;
}
static bool emulate_renault_is_rolling(FlipperFormat* ff) {
if(!ff) {
return false;
}
uint32_t rolling = 0U;
flipper_format_rewind(ff);
if(flipper_format_read_uint32(ff, "Rolling", &rolling, 1)) {
return rolling != 0U;
}
return false;
}
static bool emulate_update_data(EmulateContext* ctx, uint8_t button) {
if(!ctx || !ctx->flipper_format) return false;
flipper_format_rewind(ctx->flipper_format);
uint32_t btn_value = button;
flipper_format_insert_or_update_uint32(ctx->flipper_format, EMU_PRESET_KEY_BTN, &btn_value, 1);
FURI_LOG_I(TAG, "Updated flipper format - Btn: 0x%02X", button);
flipper_format_insert_or_update_uint32(
ctx->flipper_format, EMU_PRESET_KEY_CNT, &ctx->current_counter, 1);
FURI_LOG_I(TAG, "Updated flipper format - Cnt: 0x%03lX", (unsigned long)ctx->current_counter);
return true;
}
static void emulate_draw_callback(Canvas* canvas, void* model) {
UNUSED(model);
if(!emulate_context) return;
EmulateContext* ctx = emulate_context;
static uint8_t animation_frame = 0;
animation_frame = (animation_frame + 1) % 8;
canvas_clear(canvas);
canvas_draw_box(canvas, 0, 0, 128, 11);
canvas_invert_color(canvas);
canvas_set_font(canvas, FontSecondary);
const char* proto_name = furi_string_get_cstr(ctx->protocol_name);
canvas_draw_str_aligned(canvas, 64, 2, AlignCenter, AlignTop, proto_name);
canvas_invert_color(canvas);
canvas_set_font(canvas, FontSecondary);
char info_str[32];
if(ctx->serial <= 0xFFFFFFUL) {
snprintf(
info_str, sizeof(info_str), "SN:%06lX", (unsigned long)(ctx->serial & 0xFFFFFFUL));
} else {
snprintf(info_str, sizeof(info_str), "SN:%08lX", (unsigned long)ctx->serial);
}
canvas_draw_str(canvas, 2, 20, info_str);
snprintf(
info_str,
sizeof(info_str),
"F:%lu.%02lu",
ctx->freq / 1000000,
(ctx->freq % 1000000) / 10000);
canvas_draw_str(canvas, 2, 30, info_str);
snprintf(info_str, sizeof(info_str), "CNT:%04lX", (unsigned long)ctx->current_counter);
canvas_draw_str(canvas, 68, 20, info_str);
if(ctx->current_counter > ctx->original_counter) {
snprintf(
info_str,
sizeof(info_str),
"+%ld",
(long)(ctx->current_counter - ctx->original_counter));
canvas_draw_str(canvas, 112, 20, info_str);
}
snprintf(info_str, sizeof(info_str), "%s", ctx->preset);
canvas_draw_str(canvas, 95, 30, info_str);
canvas_set_font(canvas, FontSecondary);
const char* unlock_text = ctx->replay_only ? "REPLAY" : "UNLOCK";
uint16_t width_button = canvas_string_width(canvas, unlock_text) + 8;
uint16_t height_button = canvas_current_font_height(canvas);
canvas_draw_rbox(
canvas, 64 - (width_button / 2), 45 - (height_button / 2), width_button, height_button, 3);
canvas_invert_color(canvas);
canvas_draw_str_aligned(canvas, 64, 49, AlignCenter, AlignBottom, unlock_text);
canvas_invert_color(canvas);
char* panic_text = "PANIC";
width_button = canvas_string_width(canvas, panic_text) + 8;
canvas_draw_rbox(
canvas, 64 - (width_button / 2), 33 - (height_button / 2), width_button, height_button, 3);
canvas_invert_color(canvas);
canvas_draw_str_aligned(canvas, 64, 37, AlignCenter, AlignBottom, "LOCK");
canvas_invert_color(canvas);
canvas_draw_rbox(canvas, 0, 46 - (height_button / 2), width_button, height_button, 3);
canvas_invert_color(canvas);
canvas_draw_str_aligned(canvas, (width_button / 2), 50, AlignCenter, AlignBottom, panic_text);
canvas_invert_color(canvas);
canvas_draw_rbox(
canvas, 127 - width_button, 46 - (height_button / 2), width_button, height_button, 3);
canvas_invert_color(canvas);
canvas_draw_str_aligned(canvas, 127 - (width_button / 2), 50, AlignCenter, AlignBottom, "XXX");
canvas_invert_color(canvas);
canvas_draw_rbox(
canvas, 64 - (width_button / 2), 57 - (height_button / 2), width_button, height_button, 3);
canvas_invert_color(canvas);
canvas_draw_str_aligned(canvas, 64, 61, AlignCenter, AlignBottom, "BOOT");
canvas_invert_color(canvas);
if(ctx->is_transmitting) {
canvas_draw_rbox(canvas, 24, 18, 80, 18, 3);
canvas_invert_color(canvas);
int wave = animation_frame % 3;
canvas_draw_str(canvas, 28 + wave * 2, 25, ")))");
canvas_set_font(canvas, FontPrimary);
canvas_draw_str_aligned(canvas, 64, 24, AlignCenter, AlignCenter, "TX");
canvas_invert_color(canvas);
}
}
static bool emulate_input_callback(InputEvent* event, void* context) {
ProtoPirateApp* app = context;
EmulateContext* ctx = emulate_context;
if(!ctx) return false;
if(event->type == InputTypePress) {
if(event->key == InputKeyBack) {
view_dispatcher_send_custom_event(
app->view_dispatcher, ProtoPirateCustomEventEmulateExit);
return true;
}
if(!ctx->replay_only) {
uint8_t button = emu_button_for_protocol(
furi_string_get_cstr(ctx->protocol_name),
event->key,
ctx->original_button,
ctx->flipper_format);
if(furi_string_equal(ctx->protocol_name, RENAULT_PROTOCOL_V0_NAME)) {
ctx->current_counter = (ctx->current_counter + 1U) & 0xFFU;
} else {
ctx->current_counter++;
}
emulate_update_data(ctx, button);
}
ctx->is_transmitting = true;
view_dispatcher_send_custom_event(
app->view_dispatcher, ProtoPirateCustomEventEmulateTransmit);
return true;
} else if(event->type == InputTypeRelease) {
if(ctx && ctx->is_transmitting) {
view_dispatcher_send_custom_event(
app->view_dispatcher, ProtoPirateCustomEventEmulateStop);
return true;
}
return false;
}
return false;
}
static bool get_tx_preset_byte(const uint8_t* preset_data, size_t preset_size, uint8_t* preset_offset) {
if(!preset_data || !preset_offset) return false;
size_t offset = 0;
size_t scan_limit = preset_size < TX_PRESET_PATCH_MAX_SIZE ? preset_size :
TX_PRESET_PATCH_MAX_SIZE;
while((offset < scan_limit) && preset_data[offset]) {
offset += 2;
}
if(offset >= scan_limit) return false;
size_t tx_offset = offset + 2U;
if((tx_offset + 1U) >= scan_limit || (tx_offset + 1U) >= preset_size) return false;
*preset_offset = (uint8_t)tx_offset;
return true;
}
static void plugin_on_enter(void* context) {
ProtoPirateApp* app = context;
furi_check(g_host_api);
if(!g_host_api->ensure_view_about || !g_host_api->ensure_view_about(app)) {
notification_message(app->notifications, &sequence_error);
emulate_request_nav_pop(app);
return;
}
if(emulate_context != NULL) {
FURI_LOG_W(TAG, "Previous emulate context not freed, cleaning up");
emulate_context_free();
}
if(app->txrx && app->txrx->history && g_host_api->history_release_scratch) {
g_host_api->history_release_scratch(app);
}
if(g_host_api->rx_stack_suspend_for_tx) {
g_host_api->rx_stack_suspend_for_tx(app);
}
if(!emulate_radio_ready(app)) {
if(!g_host_api->radio_init || !g_host_api->radio_init(app)) {
FURI_LOG_E(TAG, "Failed to initialize radio for emulate scene");
notification_message(app->notifications, &sequence_error);
emulate_request_nav_pop(app);
return;
}
}
if(!emulate_radio_ready(app)) {
FURI_LOG_E(TAG, "Radio still incomplete after emulate init");
notification_message(app->notifications, &sequence_error);
emulate_request_nav_pop(app);
return;
}
emulate_context = malloc(sizeof(EmulateContext));
if(!emulate_context) {
FURI_LOG_E(TAG, "Failed to allocate emulate context");
emulate_request_nav_pop(app);
return;
}
memset(emulate_context, 0, sizeof(EmulateContext));
EmulateContext* ctx = emulate_context;
ctx->protocol_name = furi_string_alloc();
if(!ctx->protocol_name) {
FURI_LOG_E(TAG, "Failed to allocate protocol name string");
emulate_context_free();
emulate_request_nav_pop(app);
return;
}
if(!app->loaded_file_path) {
FURI_LOG_E(TAG, "No file path set");
emulate_context_free();
notification_message(app->notifications, &sequence_error);
emulate_request_nav_pop(app);
return;
}
ctx->storage = furi_record_open(RECORD_STORAGE);
if(!ctx->storage) {
FURI_LOG_E(TAG, "Failed to open storage");
emulate_context_free();
notification_message(app->notifications, &sequence_error);
emulate_request_nav_pop(app);
return;
}
ctx->flipper_format = flipper_format_file_alloc(ctx->storage);
if(!ctx->flipper_format) {
FURI_LOG_E(TAG, "Failed to allocate FlipperFormat");
emulate_context_free();
notification_message(app->notifications, &sequence_error);
emulate_request_nav_pop(app);
return;
}
if(!flipper_format_file_open_existing(
ctx->flipper_format, furi_string_get_cstr(app->loaded_file_path))) {
FURI_LOG_E(TAG, "Failed to open file: %s", furi_string_get_cstr(app->loaded_file_path));
emulate_context_free();
notification_message(app->notifications, &sequence_error);
emulate_request_nav_pop(app);
return;
}
uint32_t frequency = 433920000;
FuriString* preset_str = furi_string_alloc();
flipper_format_rewind(ctx->flipper_format);
if(!flipper_format_read_uint32(ctx->flipper_format, EMU_PRESET_KEY_FREQUENCY, &frequency, 1)) {
FURI_LOG_W(TAG, "Failed to read frequency, using default 433.92MHz");
}
flipper_format_rewind(ctx->flipper_format);
if(!flipper_format_read_string(ctx->flipper_format, EMU_PRESET_KEY_PRESET, preset_str)) {
FURI_LOG_W(TAG, "Failed to read preset, using AM650");
furi_string_set(preset_str, "AM650");
}
ctx->preset_from_file = furi_string_alloc();
furi_string_set(ctx->preset_from_file, preset_str);
ctx->preset = emu_get_short_preset_name(furi_string_get_cstr(ctx->preset_from_file));
FURI_LOG_I(
TAG,
"Using frequency %lu Hz, preset %s (from %s)",
(unsigned long)frequency,
ctx->preset,
furi_string_get_cstr(ctx->preset_from_file));
ctx->freq = frequency;
furi_string_free(preset_str);
flipper_format_rewind(ctx->flipper_format);
if(!flipper_format_read_string(
ctx->flipper_format, EMU_PRESET_KEY_PROTOCOL, ctx->protocol_name)) {
FURI_LOG_E(TAG, "Failed to read protocol name");
furi_string_set(ctx->protocol_name, "Unknown");
}
ctx->replay_only = furi_string_equal(ctx->protocol_name, RENAULT_PROTOCOL_V0_NAME) &&
!emulate_renault_is_rolling(ctx->flipper_format);
// Standalone Suzuki/Honda V0 captures: merged into Kia V0
if(furi_string_equal(ctx->protocol_name, "Suzuki") ||
furi_string_equal(ctx->protocol_name, "Suzuki V0") ||
furi_string_equal(ctx->protocol_name, "Honda V0")) {
uint32_t kia_v0_type = furi_string_equal(ctx->protocol_name, "Honda V0") ? 3U : 2U;
furi_string_set(ctx->protocol_name, KIA_PROTOCOL_V0_NAME);
flipper_format_rewind(ctx->flipper_format);
flipper_format_insert_or_update_string_cstr(
ctx->flipper_format, EMU_PRESET_KEY_PROTOCOL, KIA_PROTOCOL_V0_NAME);
flipper_format_insert_or_update_uint32(
ctx->flipper_format, EMU_PRESET_KEY_TYPE, &kia_v0_type, 1);
}
if(furi_string_equal(ctx->protocol_name, "Land Rover V0")) {
furi_string_set(ctx->protocol_name, "Honda V2");
flipper_format_rewind(ctx->flipper_format);
flipper_format_insert_or_update_string_cstr(
ctx->flipper_format, EMU_PRESET_KEY_PROTOCOL, "Honda V2");
}
const char* canonical_protocol =
protopirate_protocol_catalog_canonical_name(furi_string_get_cstr(ctx->protocol_name));
if(canonical_protocol && strcmp(furi_string_get_cstr(ctx->protocol_name), canonical_protocol) != 0) {
furi_string_set(ctx->protocol_name, canonical_protocol);
flipper_format_rewind(ctx->flipper_format);
flipper_format_insert_or_update_string_cstr(
ctx->flipper_format, EMU_PRESET_KEY_PROTOCOL, canonical_protocol);
}
flipper_format_rewind(ctx->flipper_format);
if(!flipper_format_read_uint32(ctx->flipper_format, EMU_PRESET_KEY_SERIAL, &ctx->serial, 1)) {
FURI_LOG_W(TAG, "Failed to read serial");
ctx->serial = 0;
}
flipper_format_rewind(ctx->flipper_format);
uint32_t btn_temp = 0;
if(flipper_format_read_uint32(ctx->flipper_format, EMU_PRESET_KEY_BTN, &btn_temp, 1)) {
ctx->original_button = (uint8_t)btn_temp;
}
flipper_format_rewind(ctx->flipper_format);
if(flipper_format_read_uint32(
ctx->flipper_format, EMU_PRESET_KEY_CNT, &ctx->original_counter, 1)) {
ctx->current_counter = ctx->original_counter;
}
view_set_draw_callback(app->view_about, emulate_draw_callback);
view_set_input_callback(app->view_about, emulate_input_callback);
view_set_context(app->view_about, app);
view_set_previous_callback(app->view_about, NULL);
if(furi_string_equal(ctx->protocol_name, FIAT_V1_PROTOCOL_NAME) &&
!emulate_has_hitag2_key(ctx->flipper_format)) {
if(!emulate_prompt_fiat_v1_key(app, ctx)) {
FURI_LOG_E(TAG, "Failed to show Fiat V1 HITAG2 key input");
notification_message(app->notifications, &sequence_error);
emulate_context_free();
emulate_request_nav_pop(app);
}
return;
}
view_dispatcher_switch_to_view(app->view_dispatcher, ProtoPirateViewAbout);
}
static bool plugin_on_event(void* context, SceneManagerEvent event) {
#define INVALID_PRESET "Cannot set TX power on this preset."
ProtoPirateApp* app = context;
bool consumed = false;
EmulateContext* ctx = emulate_context;
if(event.type == SceneManagerEventTypeCustom) {
switch(event.event) {
case ProtoPirateCustomEventEmulateTransmit:
if(ctx && ctx->flipper_format) {
if(app->txrx->txrx_state == ProtoPirateTxRxStateTx) {
FURI_LOG_W(TAG, "Previous transmission still active, stopping it");
if(app->txrx->radio_device) {
subghz_devices_stop_async_tx(app->txrx->radio_device);
}
subghz_transmitter_stop(ctx->transmitter);
furi_delay_ms(10);
if(app->txrx->radio_device) {
subghz_devices_idle(app->txrx->radio_device);
}
app->txrx->txrx_state = ProtoPirateTxRxStateIDLE;
}
emulate_context_reset_transmitter();
if(!emulate_context_try_init_transmitter(app, ctx)) {
FURI_LOG_E(TAG, "No transmitter available");
ctx->is_transmitting = false;
notification_message(app->notifications, &sequence_error);
consumed = true;
break;
}
EmulateResolvedPreset resolved_preset;
if(!emulate_context_resolve_tx_preset(app, ctx, &resolved_preset)) {
FURI_LOG_E(TAG, "No preset data available - cannot transmit");
ctx->is_transmitting = false;
notification_message(app->notifications, &sequence_error);
consumed = true;
break;
}
uint8_t* preset_data = resolved_preset.data;
uint8_t preset_patch_buffer[TX_PRESET_PATCH_MAX_SIZE];
if(preset_data) {
if(!emulate_radio_ready(app)) {
FURI_LOG_W(TAG, "Radio went cold before TX, reinitializing");
if(!g_host_api->radio_init || !g_host_api->radio_init(app)) {
emulate_resolved_preset_release(&resolved_preset);
emulate_context_reset_transmitter();
ctx->is_transmitting = false;
notification_message(app->notifications, &sequence_error);
consumed = true;
break;
}
}
if(app->tx_power) {
bool patchable = true;
if(!resolved_preset.should_free) {
patchable = resolved_preset.size <= sizeof(preset_patch_buffer);
if(patchable) {
memcpy(preset_patch_buffer, preset_data, resolved_preset.size);
preset_data = preset_patch_buffer;
}
}
uint8_t preset_offset = 0;
if(!patchable ||
!get_tx_preset_byte(preset_data, resolved_preset.size, &preset_offset)) {
FURI_LOG_I(TAG, INVALID_PRESET);
} else {
uint8_t fm_byte = preset_data[preset_offset];
uint8_t am_byte = preset_data[preset_offset + 1];
if(fm_byte && am_byte) {
FURI_LOG_I(TAG, INVALID_PRESET);
} else if(fm_byte) {
FURI_LOG_I(TAG, "FM PA table found.");
preset_data[preset_offset] = tx_power_value[app->tx_power];
} else if(am_byte) {
FURI_LOG_I(TAG, "AM PA table found.");
preset_data[preset_offset + 1] =
tx_power_value[TX_PRESET_VALUES_AM + app->tx_power];
} else {
FURI_LOG_I(TAG, INVALID_PRESET);
}
}
}
subghz_devices_reset(app->txrx->radio_device);
subghz_devices_idle(app->txrx->radio_device);
subghz_devices_load_preset(
app->txrx->radio_device, FuriHalSubGhzPresetCustom, preset_data);
subghz_devices_set_frequency(app->txrx->radio_device, ctx->freq);
subghz_devices_set_tx(app->txrx->radio_device);
app->start_tx_time = furi_get_tick();
if(subghz_devices_start_async_tx(
app->txrx->radio_device, subghz_transmitter_yield, ctx->transmitter)) {
app->txrx->txrx_state = ProtoPirateTxRxStateTx;
notification_message(app->notifications, &emu_sequence_tx);
notification_message(app->notifications, &sequence_blink_magenta_10);
FURI_LOG_I(
TAG,
"Started transmission: freq=%lu file_Preset=\"%s\" short=\"%s\"",
(unsigned long)ctx->freq,
ctx->preset_from_file ? furi_string_get_cstr(ctx->preset_from_file) :
"?",
ctx->preset);
} else {
FURI_LOG_E(TAG, "Failed to start async TX");
emulate_context_reset_transmitter();
ctx->is_transmitting = false;
subghz_devices_idle(app->txrx->radio_device);
notification_message(app->notifications, &sequence_error);
}
} else {
FURI_LOG_E(TAG, "No preset data available - cannot transmit");
ctx->is_transmitting = false;
notification_message(app->notifications, &sequence_error);
}
emulate_resolved_preset_release(&resolved_preset);
}
consumed = true;
break;
case ProtoPirateCustomEventEmulateStop:
FURI_LOG_I(TAG, "Stop event received, txrx_state=%d", app->txrx->txrx_state);
if(app->txrx->txrx_state == ProtoPirateTxRxStateTx && ctx) {
if((furi_get_tick() - app->start_tx_time) > emulate_min_tx_time(ctx)) {
emu_stop_tx(app);
ctx->is_transmitting = false;
} else {
ctx->flag_stop_called = true;
}
}
consumed = true;
break;
case ProtoPirateCustomEventEmulateExit:
if(app->txrx->txrx_state == ProtoPirateTxRxStateTx) {
emu_stop_tx(app);
if(ctx) {
ctx->is_transmitting = false;
ctx->flag_stop_called = false;
}
}
emulate_request_nav_after_exit(app);
consumed = true;
break;
}
} else if(event.type == SceneManagerEventTypeTick) {
view_commit_model(app->view_about, true);
if(ctx && ctx->is_transmitting) {
if(app->txrx->txrx_state == ProtoPirateTxRxStateTx) {
if((app->start_tx_time &&
((furi_get_tick() - app->start_tx_time) > emulate_min_tx_time(ctx))) &&
ctx->flag_stop_called) {
emu_stop_tx(app);
ctx->is_transmitting = false;
ctx->flag_stop_called = false;
} else {
notification_message(app->notifications, &sequence_blink_magenta_10);
}
}
}
consumed = true;
}
return consumed;
}
static void plugin_on_exit(void* context) {
ProtoPirateApp* app = context;
if(!app) {
emulate_context_free();
return;
}
// Stop any active transmission before tearing down callbacks.
if(app->txrx && app->txrx->txrx_state == ProtoPirateTxRxStateTx) {
FURI_LOG_I(TAG, "Stopping transmission on exit");
if(app->txrx->radio_device) {
subghz_devices_stop_async_tx(app->txrx->radio_device);
} else {
FURI_LOG_W(TAG, "Emulate exit saw TX state without radio device");
}
if(emulate_context && emulate_context->transmitter) {
subghz_transmitter_stop(emulate_context->transmitter);
}
furi_delay_ms(10);
if(app->txrx->radio_device) {
subghz_devices_idle(app->txrx->radio_device);
}
app->txrx->txrx_state = ProtoPirateTxRxStateIDLE;
} else if(app->txrx && app->txrx->txrx_state != ProtoPirateTxRxStateIDLE) {
if(g_host_api && g_host_api->idle) g_host_api->idle(app);
}
emulate_context_free();
if(g_host_api && g_host_api->storage_delete_temp) g_host_api->storage_delete_temp();
if(app->radio_initialized && app->txrx && app->txrx->environment && app->txrx->preset &&
app->txrx->preset->data && app->txrx->preset->name &&
g_host_api && g_host_api->apply_protocol_registry_for_context) {
const char* preset_name = furi_string_get_cstr(app->txrx->preset->name);
if(preset_name) {
if(!g_host_api->apply_protocol_registry_for_context(
app,
preset_name,
app->txrx->preset->frequency,
app->txrx->preset->data,
app->txrx->preset->data_size,
NULL)) {
FURI_LOG_W(TAG, "Failed to restore session protocol registry on emulate exit");
}
}
}
if(app->view_about) {
view_set_draw_callback(app->view_about, NULL);
view_set_input_callback(app->view_about, NULL);
view_set_context(app->view_about, NULL);
}
if(app->notifications) {
notification_message_block(app->notifications, &sequence_blink_stop);
}
}
static void plugin_context_release(void* context) {
UNUSED(context);
emulate_context_free();
}
static void plugin_set_host_api(const ProtoPirateEmulateHostApi* host_api) {
g_host_api = host_api;
}
static const ProtoPirateEmulatePlugin protopirate_emulate_plugin = {
.plugin_name = "ProtoPirate Emulate",
.set_host_api = plugin_set_host_api,
.on_enter = plugin_on_enter,
.on_event = plugin_on_event,
.on_exit = plugin_on_exit,
.context_release = plugin_context_release,
};
static const FlipperAppPluginDescriptor protopirate_emulate_plugin_descriptor = {
.appid = PROTOPIRATE_EMULATE_PLUGIN_APP_ID,
.ep_api_version = PROTOPIRATE_EMULATE_PLUGIN_API_VERSION,
.entry_point = &protopirate_emulate_plugin,
};
const FlipperAppPluginDescriptor* protopirate_emulate_plugin_ep(void) {
return &protopirate_emulate_plugin_descriptor;
}
#endif // ENABLE_EMULATE_FEATURE