Luu 11 месяцев назад
Родитель
Сommit
a57e3f45f1
43 измененных файлов с 2243 добавлено и 1796 удалено
  1. 1 0
      api/calypso/calypso_util.c
  2. 0 12
      api/calypso/calypso_util.h
  3. 1 0
      api/calypso/cards/intercode.c
  4. 0 8
      api/calypso/cards/intercode.h
  5. 1 1
      api/calypso/cards/opus.c
  6. 0 12
      api/calypso/cards/opus.h
  7. 1 1
      api/calypso/cards/ravkav.c
  8. 0 12
      api/calypso/cards/ravkav.h
  9. 1 0
      api/calypso/transit/navigo.c
  10. 0 14
      api/calypso/transit/navigo.h
  11. 3 1
      api/calypso/transit/opus.c
  12. 0 20
      api/calypso/transit/opus.h
  13. 3 1
      api/calypso/transit/ravkav.c
  14. 0 14
      api/calypso/transit/ravkav.h
  15. 138 0
      api/metroflip/metroflip_api.h
  16. 9 0
      api/metroflip/metroflip_api_interface.h
  17. 27 0
      api/metroflip/metroflip_api_table.cpp
  18. 71 0
      api/metroflip/metroflip_api_table_i.h
  19. 1 0
      api/nfc/mf_classic_key_cache.c
  20. 0 10
      api/nfc/mf_classic_key_cache.h
  21. 88 0
      application.fam
  22. 27 8
      metroflip.c
  23. 13 33
      metroflip_i.h
  24. 16 0
      metroflip_plugins.h
  25. 37 160
      scenes/keys.c
  26. 1 0
      scenes/metroflip_scene_about.c
  27. 18 10
      scenes/metroflip_scene_auto.c
  28. 0 11
      scenes/metroflip_scene_calypso.h
  29. 1 11
      scenes/metroflip_scene_config.h
  30. 1 0
      scenes/metroflip_scene_credits.c
  31. 1 3
      scenes/metroflip_scene_ovc.c
  32. 68 0
      scenes/metroflip_scene_parse.c
  33. 0 56
      scenes/metroflip_scene_read_success.c
  34. 48 9
      scenes/plugins/bip.c
  35. 30 11
      scenes/plugins/calypso.c
  36. 1331 1287
      scenes/plugins/charliecard.c
  37. 29 9
      scenes/plugins/clipper.c
  38. 29 9
      scenes/plugins/itso.c
  39. 68 23
      scenes/plugins/metromoney.c
  40. 29 9
      scenes/plugins/myki.c
  41. 29 9
      scenes/plugins/opal.c
  42. 30 19
      scenes/plugins/smartrider.c
  43. 92 13
      scenes/plugins/troika.c

+ 1 - 0
api/calypso/calypso_util.c

@@ -2,6 +2,7 @@
 #include <stdlib.h>
 #include <string.h>
 #include "calypso_util.h"
+#include "../metroflip/metroflip_api.h"
 
 CalypsoElement make_calypso_final_element(
     const char* key,

+ 0 - 12
api/calypso/calypso_util.h

@@ -91,24 +91,12 @@ CalypsoElement make_calypso_container_element(const char* key, int size, Calypso
 
 CalypsoElement make_calypso_repeater_element(const char* key, int size, CalypsoElement element);
 
-void free_calypso_structure(CalypsoApp* structure);
-
 int* get_bitmap_positions(const char* binary_string, int* count);
 
 int is_bit_present(int* positions, int count, int bit);
 
-bool is_calypso_node_present(const char* binary_string, const char* key, CalypsoApp* structure);
-
-int get_calypso_node_offset(const char* binary_string, const char* key, CalypsoApp* structure);
-
-int get_calypso_node_size(const char* key, CalypsoApp* structure);
-
 // Calypso known Card types
 
-CALYPSO_CARD_TYPE guess_card_type(int country_num, int network_num);
-
 const char* get_country_string(int country_num);
 
-const char* get_network_string(CALYPSO_CARD_TYPE card_type);
-
 #endif // CALYPSO_UTIL_H

+ 1 - 0
api/calypso/cards/intercode.c

@@ -1,5 +1,6 @@
 #include <stdlib.h>
 #include "intercode.h"
+#include "../../metroflip/metroflip_api.h"
 
 CalypsoApp* get_intercode_structure_env_holder() {
     CalypsoApp* IntercodeEnvHolderStructure = malloc(sizeof(CalypsoApp));

+ 0 - 8
api/calypso/cards/intercode.h

@@ -3,14 +3,6 @@
 #ifndef INTERCODE_STRUCTURES_H
 #define INTERCODE_STRUCTURES_H
 
-CalypsoApp* get_intercode_structure_env_holder();
-
-CalypsoApp* get_intercode_structure_contract();
-
-CalypsoApp* get_intercode_structure_event();
-
-CalypsoApp* get_intercode_structure_counter();
-
 const char* get_intercode_string_transition_type(int transition);
 
 const char* get_intercode_string_transport_type(int type);

+ 1 - 1
api/calypso/cards/opus.c

@@ -1,5 +1,5 @@
 #include <stdlib.h>
-#include "opus.h"
+#include "../../metroflip/metroflip_api.h"
 
 CalypsoApp* get_opus_contract_structure() {
     CalypsoApp* OpusContractStructure = malloc(sizeof(CalypsoApp));

+ 0 - 12
api/calypso/cards/opus.h

@@ -1,12 +0,0 @@
-#include "../calypso_util.h"
-
-#ifndef OPUS_STRUCTURES_H
-#define OPUS_STRUCTURES_H
-
-CalypsoApp* get_opus_contract_structure();
-
-CalypsoApp* get_opus_event_structure();
-
-CalypsoApp* get_opus_env_holder_structure();
-
-#endif // OPUS_STRUCTURES_H

+ 1 - 1
api/calypso/cards/ravkav.c

@@ -1,5 +1,5 @@
 #include <stdlib.h>
-#include "ravkav.h"
+#include "../../metroflip/metroflip_api.h"
 
 CalypsoApp* get_ravkav_contract_structure() {
     /*

+ 0 - 12
api/calypso/cards/ravkav.h

@@ -1,12 +0,0 @@
-#include "../calypso_util.h"
-
-#ifndef RAVKAV_STRUCTURES_H
-#define RAVKAV_STRUCTURES_H
-
-CalypsoApp* get_ravkav_contract_structure();
-
-CalypsoApp* get_ravkav_event_structure();
-
-CalypsoApp* get_ravkav_env_holder_structure();
-
-#endif // RAVKAV_STRUCTURES_H

+ 1 - 0
api/calypso/transit/navigo.c

@@ -1,6 +1,7 @@
 #include "navigo.h"
 #include "navigo_lists.h"
 #include "../../../metroflip_i.h"
+#include "../../metroflip/metroflip_api.h"
 
 const char* get_navigo_service_provider(int provider) {
     switch(provider) {

+ 0 - 14
api/calypso/transit/navigo.h

@@ -25,20 +25,6 @@ const char* get_navigo_sncf_station(int station_group_id, int station_id);
 
 const char* get_navigo_tram_line(int route_number);
 
-void show_navigo_event_info(
-    NavigoCardEvent* event,
-    NavigoCardContract* contracts,
-    FuriString* parsed_data);
-
-void show_navigo_special_event_info(NavigoCardSpecialEvent* event, FuriString* parsed_data);
-
-void show_navigo_contract_info(NavigoCardContract* contract, FuriString* parsed_data);
-
-void show_navigo_environment_info(
-    NavigoCardEnv* environment,
-    NavigoCardHolder* holder,
-    FuriString* parsed_data);
-
 typedef enum {
     NAVIGO_PROVIDER_SNCF = 2,
     NAVIGO_PROVIDER_RATP = 3,

+ 3 - 1
api/calypso/transit/opus.c

@@ -1,6 +1,8 @@
-#include "opus.h"
 #include "opus_lists.h"
 #include "../../../metroflip_i.h"
+#include "../../metroflip/metroflip_api.h"
+#include "../calypso_util.h"
+#include "opus_i.h"
 
 const char* get_opus_service_provider(int provider) {
     switch(provider) {

+ 0 - 20
api/calypso/transit/opus.h

@@ -1,20 +0,0 @@
-#include "../calypso_util.h"
-#include "../cards/opus.h"
-#include "opus_i.h"
-
-#ifndef OPUS_H
-#define OPUS_H
-
-void show_opus_event_info(
-    OpusCardEvent* event,
-    OpusCardContract* contracts,
-    FuriString* parsed_data);
-
-void show_opus_contract_info(OpusCardContract* contract, FuriString* parsed_data);
-
-void show_opus_environment_info(
-    OpusCardEnv* environment,
-    OpusCardHolder* holder,
-    FuriString* parsed_data);
-
-#endif // OPUS_H

+ 3 - 1
api/calypso/transit/ravkav.c

@@ -1,6 +1,8 @@
-#include "ravkav.h"
 #include "ravkav_lists.h"
 #include "../../../metroflip_i.h"
+#include "../../metroflip/metroflip_api.h"
+#include "../calypso_util.h"
+#include "ravkav_i.h"
 
 const char* get_ravkav_issuer(int issuer) {
     if(RAVKAV_ISSUERS_LIST[issuer]) {

+ 0 - 14
api/calypso/transit/ravkav.h

@@ -1,14 +0,0 @@
-#include "../calypso_util.h"
-#include "../cards/ravkav.h"
-#include "ravkav_i.h"
-
-#ifndef RAVKAV_H
-#define RAVKAV_H
-
-void show_ravkav_event_info(RavKavCardEvent* event, FuriString* parsed_data);
-
-void show_ravkav_contract_info(RavKavCardContract* contract, FuriString* parsed_data);
-
-void show_ravkav_environment_info(RavKavCardEnv* environment, FuriString* parsed_data);
-
-#endif // RAVKAV_H

+ 138 - 0
api/metroflip/metroflip_api.h

@@ -0,0 +1,138 @@
+/**
+ * @file metroflip_api.h
+ * @brief Application API example.
+ *
+ * This file contains an API that is internally implemented by the application
+ * It is also exposed to plugins to allow them to use the application's API.
+ */
+#pragma once
+
+#include <stdint.h>
+#include "../../metroflip_i.h"
+#include <gui/gui.h>
+#include <gui/modules/widget_elements/widget_element.h>
+#include "../calypso/calypso_i.h"
+#include "../calypso/calypso_util.h"
+#include <datetime.h>
+#include <stdbool.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+// metroflip
+
+void metroflip_exit_widget_callback(GuiButtonType result, InputType type, void* context);
+
+void metroflip_app_blink_start(Metroflip* metroflip);
+
+void metroflip_app_blink_stop(Metroflip* metroflip);
+
+int bit_slice_to_dec(const char* bit_representation, int start, int end);
+
+void byte_to_binary(uint8_t byte, char* bits);
+
+bool mf_classic_key_cache_load(MfClassicKeyCache* instance, const uint8_t* uid, size_t uid_len);
+
+bool mf_classic_key_cache_get_next_key(
+    MfClassicKeyCache* instance,
+    uint8_t* sector_num,
+    MfClassicKey* key,
+    MfClassicKeyType* key_type);
+
+void mf_classic_key_cache_reset(MfClassicKeyCache* instance);
+
+KeyfileManager manage_keyfiles(
+    char uid_str[],
+    const uint8_t* uid,
+    size_t uid_len,
+    MfClassicKeyCache* instance,
+    uint64_t key_mask_a_required,
+    uint64_t key_mask_b_required);
+
+void uid_to_string(const uint8_t* uid, size_t uid_len, char* uid_str, size_t max_len);
+
+void handle_keyfile_case(
+    Metroflip* app,
+    const char* message_title,
+    const char* log_message,
+    FuriString* parsed_data,
+    char card_type[]);
+
+extern uint8_t read_file[5];
+extern uint8_t apdu_success[2];
+extern uint8_t select_app[8];
+
+/*****  calypso *****/
+int get_calypso_node_offset(const char* binary_string, const char* key, CalypsoApp* structure);
+const char* get_network_string(CALYPSO_CARD_TYPE card_type);
+void metroflip_back_button_widget_callback(GuiButtonType result, InputType type, void* context);
+void metroflip_next_button_widget_callback(GuiButtonType result, InputType type, void* context);
+bool is_calypso_node_present(const char* binary_string, const char* key, CalypsoApp* structure);
+int get_calypso_node_size(const char* key, CalypsoApp* structure);
+void free_calypso_structure(CalypsoApp* structure);
+CALYPSO_CARD_TYPE guess_card_type(int country_num, int network_num);
+
+// intercode
+
+CalypsoApp* get_intercode_structure_env_holder();
+
+CalypsoApp* get_intercode_structure_contract();
+
+CalypsoApp* get_intercode_structure_event();
+
+CalypsoApp* get_intercode_structure_counter();
+
+//navigo
+
+void show_navigo_event_info(
+    NavigoCardEvent* event,
+    NavigoCardContract* contracts,
+    FuriString* parsed_data);
+
+void show_navigo_special_event_info(NavigoCardSpecialEvent* event, FuriString* parsed_data);
+
+void show_navigo_contract_info(NavigoCardContract* contract, FuriString* parsed_data);
+
+void show_navigo_environment_info(
+    NavigoCardEnv* environment,
+    NavigoCardHolder* holder,
+    FuriString* parsed_data);
+
+// opus
+
+CalypsoApp* get_opus_contract_structure();
+
+CalypsoApp* get_opus_event_structure();
+
+CalypsoApp* get_opus_env_holder_structure();
+
+void show_opus_event_info(
+    OpusCardEvent* event,
+    OpusCardContract* contracts,
+    FuriString* parsed_data);
+
+void show_opus_contract_info(OpusCardContract* contract, FuriString* parsed_data);
+
+void show_opus_environment_info(
+    OpusCardEnv* environment,
+    OpusCardHolder* holder,
+    FuriString* parsed_data);
+
+//ravkav
+
+CalypsoApp* get_ravkav_contract_structure();
+
+CalypsoApp* get_ravkav_event_structure();
+
+CalypsoApp* get_ravkav_env_holder_structure();
+
+void show_ravkav_event_info(RavKavCardEvent* event, FuriString* parsed_data);
+
+void show_ravkav_contract_info(RavKavCardContract* contract, FuriString* parsed_data);
+
+void show_ravkav_environment_info(RavKavCardEnv* environment, FuriString* parsed_data);
+/*******************/
+#ifdef __cplusplus
+}
+#endif

+ 9 - 0
api/metroflip/metroflip_api_interface.h

@@ -0,0 +1,9 @@
+#pragma once
+
+#include <flipper_application/api_hashtable/api_hashtable.h>
+
+/* 
+ * Resolver interface with private application's symbols. 
+ * Implementation is contained in metroflip_api_table.c
+ */
+extern const ElfApiInterface* const metroflip_api_interface;

+ 27 - 0
api/metroflip/metroflip_api_table.cpp

@@ -0,0 +1,27 @@
+#include <flipper_application/api_hashtable/api_hashtable.h>
+#include <flipper_application/api_hashtable/compilesort.hpp>
+
+/* 
+ * This file contains an implementation of a symbol table 
+ * with private app's symbols. It is used by composite API resolver
+ * to load plugins that use internal application's APIs.
+ */
+#include "metroflip_api_table_i.h"
+
+static_assert(!has_hash_collisions(metroflip_api_table), "Detected API method hash collision!");
+
+constexpr HashtableApiInterface applicaton_hashtable_api_interface{
+    {
+        .api_version_major = 0,
+        .api_version_minor = 0,
+        /* generic resolver using pre-sorted array */
+        .resolver_callback = &elf_resolve_from_hashtable,
+    },
+    /* pointers to application's API table boundaries */
+    metroflip_api_table.cbegin(),
+    metroflip_api_table.cend(),
+};
+
+/* Casting to generic resolver to use in Composite API resolver */
+extern "C" const ElfApiInterface* const metroflip_api_interface =
+    &applicaton_hashtable_api_interface;

+ 71 - 0
api/metroflip/metroflip_api_table_i.h

@@ -0,0 +1,71 @@
+#include "metroflip_api.h"
+#include "../../metroflip_i.h"
+
+/* 
+ * A list of app's private functions and objects to expose for plugins.
+ * It is used to generate a table of symbols for import resolver to use.
+ * TBD: automatically generate this table from app's header files
+ */
+static constexpr auto metroflip_api_table = sort(create_array_t<sym_entry>(
+    // metroflip stuff
+    API_METHOD(metroflip_exit_widget_callback, void, (GuiButtonType, InputType, void*)),
+    API_METHOD(metroflip_app_blink_start, void, (Metroflip*)),
+    API_METHOD(metroflip_app_blink_stop, void, (Metroflip*)),
+    API_METHOD(bit_slice_to_dec, int, (const char*, int, int)),
+    API_METHOD(byte_to_binary, void, (uint8_t, char*)),
+    API_VARIABLE(read_file, uint8_t[5]),
+    API_VARIABLE(apdu_success, uint8_t[2]),
+    API_VARIABLE(select_app, uint8_t[8]),
+    API_METHOD(mf_classic_key_cache_load, bool, (MfClassicKeyCache*, const uint8_t*, size_t)),
+    API_METHOD(
+        mf_classic_key_cache_get_next_key,
+        bool,
+        (MfClassicKeyCache*, uint8_t*, MfClassicKey*, MfClassicKeyType*)),
+    API_METHOD(mf_classic_key_cache_reset, void, (MfClassicKeyCache*)),
+    API_METHOD(
+        manage_keyfiles,
+        KeyfileManager,
+        (char[], const uint8_t*, size_t, MfClassicKeyCache*, uint64_t, uint64_t)),
+    API_METHOD(uid_to_string, void, (const uint8_t*, size_t, char*, size_t)),
+    API_METHOD(
+        handle_keyfile_case,
+        void,
+        (Metroflip*, const char*, const char*, FuriString*, char[])),
+
+    // calypso
+    API_METHOD(get_calypso_node_offset, int, (const char*, const char*, CalypsoApp*)),
+    API_METHOD(get_network_string, const char*, (CALYPSO_CARD_TYPE)),
+    API_METHOD(metroflip_back_button_widget_callback, void, (GuiButtonType, InputType, void*)),
+    API_METHOD(metroflip_next_button_widget_callback, void, (GuiButtonType, InputType, void*)),
+    API_METHOD(is_calypso_node_present, bool, (const char*, const char*, CalypsoApp*)),
+    API_METHOD(get_calypso_node_size, int, (const char*, CalypsoApp*)),
+    API_METHOD(free_calypso_structure, void, (CalypsoApp*)),
+    API_METHOD(guess_card_type, CALYPSO_CARD_TYPE, (int, int)),
+
+    // intercode
+    API_METHOD(get_intercode_structure_env_holder, CalypsoApp*, ()),
+    API_METHOD(get_intercode_structure_contract, CalypsoApp*, ()),
+    API_METHOD(get_intercode_structure_event, CalypsoApp*, ()),
+    API_METHOD(get_intercode_structure_counter, CalypsoApp*, ()),
+
+    // navigo
+    API_METHOD(show_navigo_event_info, void, (NavigoCardEvent*, NavigoCardContract*, FuriString*)),
+    API_METHOD(show_navigo_special_event_info, void, (NavigoCardSpecialEvent*, FuriString*)),
+    API_METHOD(show_navigo_contract_info, void, (NavigoCardContract*, FuriString*)),
+    API_METHOD(show_navigo_environment_info, void, (NavigoCardEnv*, NavigoCardHolder*, FuriString*)),
+
+    // opus
+    API_METHOD(get_opus_contract_structure, CalypsoApp*, ()),
+    API_METHOD(get_opus_event_structure, CalypsoApp*, ()),
+    API_METHOD(get_opus_env_holder_structure, CalypsoApp*, ()),
+    API_METHOD(show_opus_event_info, void, (OpusCardEvent*, OpusCardContract*, FuriString*)),
+    API_METHOD(show_opus_contract_info, void, (OpusCardContract*, FuriString*)),
+    API_METHOD(show_opus_environment_info, void, (OpusCardEnv*, OpusCardHolder*, FuriString*)),
+
+    // ravkav
+    API_METHOD(get_ravkav_contract_structure, CalypsoApp*, ()),
+    API_METHOD(get_ravkav_event_structure, CalypsoApp*, ()),
+    API_METHOD(get_ravkav_env_holder_structure, CalypsoApp*, ()),
+    API_METHOD(show_ravkav_event_info, void, (RavKavCardEvent*, FuriString*)),
+    API_METHOD(show_ravkav_contract_info, void, (RavKavCardContract*, FuriString*)),
+    API_METHOD(show_ravkav_environment_info, void, (RavKavCardEnv*, FuriString*))));

+ 1 - 0
api/nfc/mf_classic_key_cache.c

@@ -2,6 +2,7 @@
 
 #include <furi/furi.h>
 #include <storage/storage.h>
+#include "../metroflip/metroflip_api.h"
 
 #define NFC_APP_KEYS_EXTENSION   ".keys"
 #define NFC_APP_KEY_CACHE_FOLDER "/ext/nfc/.cache"

+ 0 - 10
api/nfc/mf_classic_key_cache.h

@@ -12,20 +12,10 @@ MfClassicKeyCache* mf_classic_key_cache_alloc(void);
 
 void mf_classic_key_cache_free(MfClassicKeyCache* instance);
 
-bool mf_classic_key_cache_load(MfClassicKeyCache* instance, const uint8_t* uid, size_t uid_len);
-
 void mf_classic_key_cache_load_from_data(MfClassicKeyCache* instance, const MfClassicData* data);
 
-bool mf_classic_key_cache_get_next_key(
-    MfClassicKeyCache* instance,
-    uint8_t* sector_num,
-    MfClassicKey* key,
-    MfClassicKeyType* key_type);
-
 bool mf_classic_key_cache_save(MfClassicKeyCache* instance, const MfClassicData* data);
 
-void mf_classic_key_cache_reset(MfClassicKeyCache* instance);
-
 #ifdef __cplusplus
 }
 #endif

+ 88 - 0
application.fam

@@ -12,3 +12,91 @@ App(
     fap_icon_assets="images",  # Image assets to compile for this application
     fap_file_assets="files",
 )
+
+App(
+    appid="charliecard_plugin",
+    apptype=FlipperAppType.PLUGIN,
+    entry_point="charliecard_plugin_ep",
+    requires=["metroflip"],
+    sources=["scenes/plugins/charliecard.c"],
+    fal_embedded=True,
+)
+
+App(
+    appid="calypso_plugin",
+    apptype=FlipperAppType.PLUGIN,
+    entry_point="calypso_plugin_ep",
+    requires=["metroflip"],
+    sources=["scenes/plugins/calypso.c"],
+    fal_embedded=True,
+)
+
+App(
+    appid="metromoney_plugin",
+    apptype=FlipperAppType.PLUGIN,
+    entry_point="metromoney_plugin_ep",
+    requires=["metroflip"],
+    sources=["scenes/plugins/metromoney.c"],
+)
+
+App(
+    appid="bip_plugin",
+    apptype=FlipperAppType.PLUGIN,
+    entry_point="bip_plugin_ep",
+    requires=["metroflip"],
+    sources=["scenes/plugins/bip.c"],
+)
+
+App(
+    appid="clipper_plugin",
+    apptype=FlipperAppType.PLUGIN,
+    entry_point="clipper_plugin_ep",
+    requires=["metroflip"],
+    sources=["scenes/plugins/clipper.c"],
+    fal_embedded=True,
+)
+
+App(
+    appid="itso_plugin",
+    apptype=FlipperAppType.PLUGIN,
+    entry_point="itso_plugin_ep",
+    requires=["metroflip"],
+    sources=["scenes/plugins/itso.c"],
+    fal_embedded=True,
+)
+
+App(
+    appid="myki_plugin",
+    apptype=FlipperAppType.PLUGIN,
+    entry_point="myki_plugin_ep",
+    requires=["metroflip"],
+    sources=["scenes/plugins/myki.c"],
+    fal_embedded=True,
+)
+
+App(
+    appid="opal_plugin",
+    apptype=FlipperAppType.PLUGIN,
+    entry_point="opal_plugin_ep",
+    requires=["metroflip"],
+    sources=["scenes/plugins/opal.c"],
+    fal_embedded=True,
+)
+
+App(
+    appid="smartrider_plugin",
+    apptype=FlipperAppType.PLUGIN,
+    entry_point="smartrider_plugin_ep",
+    requires=["metroflip"],
+    sources=["scenes/plugins/smartrider.c"],
+    fal_embedded=True,
+)
+
+App(
+    appid="troika_plugin",
+    apptype=FlipperAppType.PLUGIN,
+    entry_point="troika_plugin_ep",
+    requires=["metroflip"],
+    sources=["scenes/plugins/troika.c"],
+    fal_embedded=True,
+)

+ 27 - 8
metroflip.c

@@ -1,6 +1,10 @@
 
 #include "metroflip_i.h"
 
+#define TAG "Metroflip"
+#include "api/metroflip/metroflip_api.h"
+#include "api/metroflip/metroflip_api_interface.h"
+#include "metroflip_plugins.h"
 struct MfClassicKeyCache {
     MfClassicDeviceKeys keys;
     MfClassicKeyType current_key_type;
@@ -213,21 +217,26 @@ int bit_slice_to_dec(const char* bit_representation, int start, int end) {
 
 extern int32_t metroflip(void* p) {
     UNUSED(p);
+
+    /*
+    plugin_manager_load_single(PluginManager * manager, const char* path)
+        uint32_t plugin_count = plugin_manager_get_count(manager);
+    FURI_LOG_I(TAG, "Loaded %lu plugin(s)", plugin_count);
+
+    for(uint32_t i = 0; i < plugin_count; i++) {
+        const MetroflipPlugin* plugin = plugin_manager_get_ep(manager, i);
+        FURI_LOG_I(TAG, "plugin name: %s", plugin->name);
+    }
+    */
+
     Metroflip* app = metroflip_alloc();
-    scene_manager_set_scene_state(app->scene_manager, MetroflipSceneStart, MetroflipSceneCalypso);
+    scene_manager_set_scene_state(app->scene_manager, MetroflipSceneStart, MetroflipSceneAuto);
     scene_manager_next_scene(app->scene_manager, MetroflipSceneStart);
     view_dispatcher_run(app->view_dispatcher);
     metroflip_free(app);
     return 0;
 }
 
-void dec_to_bits(char dec_representation, char* bit_representation) {
-    int decimal = dec_representation - '0';
-    for(int i = 7; i >= 0; --i) {
-        bit_representation[i] = (decimal & (1 << i)) ? '1' : '0';
-    }
-}
-
 KeyfileManager manage_keyfiles(
     char uid_str[],
     const uint8_t* uid,
@@ -363,3 +372,13 @@ void handle_keyfile_case(
     view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewWidget);
     metroflip_app_blink_stop(app);
 }
+
+void metroflip_plugin_manager_alloc(Metroflip* app) {
+    app->resolver = composite_api_resolver_alloc();
+    composite_api_resolver_add(app->resolver, firmware_api_interface);
+    composite_api_resolver_add(app->resolver, metroflip_api_interface);
+    app->plugin_manager = plugin_manager_alloc(
+        METROFLIP_SUPPORTED_CARD_PLUGIN_APP_ID,
+        METROFLIP_SUPPORTED_CARD_PLUGIN_API_VERSION,
+        composite_api_resolver_get(app->resolver));
+}

+ 13 - 33
metroflip_i.h

@@ -14,7 +14,11 @@
 #else
 extern const Icon I_RFIDDolphinReceive_97x61;
 #endif
-
+#include <flipper_application/plugins/composite_resolver.h>
+#include <loader/firmware_api/firmware_api.h>
+#include <flipper_application/plugins/plugin_manager.h>
+#include <loader/firmware_api/firmware_api.h>
+#include <flipper_application/flipper_application.h>
 #include <gui/modules/submenu.h>
 #include <gui/modules/popup.h>
 #include <gui/modules/loading.h>
@@ -73,6 +77,12 @@ typedef struct {
     NfcDetectedProtocols* detected_protocols;
     DesfireCardType desfire_card_type;
 
+    //plugin manager
+    PluginManager* plugin_manager;
+
+    //api
+    CompositeApiResolver* resolver;
+
     // card details:
     uint32_t balance_lari;
     uint8_t balance_tetri;
@@ -135,17 +145,6 @@ typedef enum {
     MISSING_KEYFILE
 } KeyfileManager;
 
-KeyfileManager manage_keyfiles(
-    char uid_str[],
-    const uint8_t* uid,
-    size_t uid_len,
-    MfClassicKeyCache* instance,
-    uint64_t key_mask_a_required,
-    uint64_t key_mask_b_required);
-
-void metroflip_app_blink_start(Metroflip* metroflip);
-void metroflip_app_blink_stop(Metroflip* metroflip);
-
 CardType determine_card_type(Nfc* nfc);
 
 #ifdef FW_ORIGIN_Official
@@ -154,19 +153,10 @@ CardType determine_card_type(Nfc* nfc);
     if(!(locked)) submenu_add_item(submenu, label, index, callback, callback_context)
 #endif
 
-void metroflip_exit_widget_callback(GuiButtonType result, InputType type, void* context);
-
-void uid_to_string(const uint8_t* uid, size_t uid_len, char* uid_str, size_t max_len);
-
-void handle_keyfile_case(
-    Metroflip* app,
-    const char* message_title,
-    const char* log_message,
-    FuriString* parsed_data,
-    char card_type[]);
-
 char* bit_slice(const char* bit_representation, int start, int end);
 
+void metroflip_plugin_manager_alloc(Metroflip* app);
+
 ///////////////////////////////// Calypso / EN1545 /////////////////////////////////
 
 #define Metroflip_POLLER_MAX_BUFFER_SIZE 1024
@@ -175,14 +165,4 @@ char* bit_slice(const char* bit_representation, int start, int end);
 
 void locale_format_datetime_cat(FuriString* out, const DateTime* dt, bool time);
 
-extern uint8_t read_file[5];
-extern uint8_t apdu_success[2];
-extern uint8_t select_app[8];
-
-void byte_to_binary(uint8_t byte, char* bits);
-
 int binary_to_decimal(const char binary[]);
-
-int bit_slice_to_dec(const char* bit_representation, int start, int end);
-
-void dec_to_bits(char dec_representation, char* bit_representation);

+ 16 - 0
metroflip_plugins.h

@@ -0,0 +1,16 @@
+#include <furi/core/string.h>
+#include "metroflip_i.h"
+
+#define METROFLIP_SUPPORTED_CARD_PLUGIN_APP_ID      "metroflip_plugins"
+#define METROFLIP_SUPPORTED_CARD_PLUGIN_API_VERSION 1
+
+typedef void (*MetrolfipSupportedCardPluginOnEnter)(Metroflip* app);
+typedef bool (*MetrolfipSupportedCardPluginOnEvent)(Metroflip* app, SceneManagerEvent event);
+typedef void (*MetrolfipSupportedCardPluginOnExit)(Metroflip* app);
+
+typedef struct {
+    const char* card_name;
+    MetrolfipSupportedCardPluginOnEnter plugin_on_enter;
+    MetrolfipSupportedCardPluginOnEvent plugin_on_event;
+    MetrolfipSupportedCardPluginOnExit plugin_on_exit;
+} MetroflipPlugin;

+ 37 - 160
scenes/keys.c

@@ -9,128 +9,28 @@
 
 #define TAG "keys_check"
 
-const MfClassicKeyPair troika_1k_keys[16] = {
-    {.a = 0xa0a1a2a3a4a5, .b = 0xfbf225dc5d58},
-    {.a = 0xa82607b01c0d, .b = 0x2910989b6880},
-    {.a = 0x2aa05ed1856f, .b = 0xeaac88e5dc99},
-    {.a = 0x2aa05ed1856f, .b = 0xeaac88e5dc99},
-    {.a = 0x73068f118c13, .b = 0x2b7f3253fac5},
-    {.a = 0xfbc2793d540b, .b = 0xd3a297dc2698},
-    {.a = 0x2aa05ed1856f, .b = 0xeaac88e5dc99},
-    {.a = 0xae3d65a3dad4, .b = 0x0f1c63013dba},
-    {.a = 0xa73f5dc1d333, .b = 0xe35173494a81},
-    {.a = 0x69a32f1c2f19, .b = 0x6b8bd9860763},
-    {.a = 0x9becdf3d9273, .b = 0xf8493407799d},
-    {.a = 0x08b386463229, .b = 0x5efbaecef46b},
-    {.a = 0xcd4c61c26e3d, .b = 0x31c7610de3b0},
-    {.a = 0xa82607b01c0d, .b = 0x2910989b6880},
-    {.a = 0x0e8f64340ba4, .b = 0x4acec1205d75},
-    {.a = 0x2aa05ed1856f, .b = 0xeaac88e5dc99},
+const MfClassicKeyPair troika_1k_key[1] = {
+    {.a = 0x08b386463229},
 };
 
-const MfClassicKeyPair troika_4k_keys[40] = {
-    {.a = 0xEC29806D9738, .b = 0xFBF225DC5D58}, //1
-    {.a = 0xA0A1A2A3A4A5, .b = 0x7DE02A7F6025}, //2
-    {.a = 0x2AA05ED1856F, .b = 0xEAAC88E5DC99}, //3
-    {.a = 0x2AA05ED1856F, .b = 0xEAAC88E5DC99}, //4
-    {.a = 0x73068F118C13, .b = 0x2B7F3253FAC5}, //5
-    {.a = 0xFBC2793D540B, .b = 0xD3A297DC2698}, //6
-    {.a = 0x2AA05ED1856F, .b = 0xEAAC88E5DC99}, //7
-    {.a = 0xAE3D65A3DAD4, .b = 0x0F1C63013DBA}, //8
-    {.a = 0xA73F5DC1D333, .b = 0xE35173494A81}, //9
-    {.a = 0x69A32F1C2F19, .b = 0x6B8BD9860763}, //10
-    {.a = 0x9BECDF3D9273, .b = 0xF8493407799D}, //11
-    {.a = 0x08B386463229, .b = 0x5EFBAECEF46B}, //12
-    {.a = 0xCD4C61C26E3D, .b = 0x31C7610DE3B0}, //13
-    {.a = 0xA82607B01C0D, .b = 0x2910989B6880}, //14
-    {.a = 0x0E8F64340BA4, .b = 0x4ACEC1205D75}, //15
-    {.a = 0x2AA05ED1856F, .b = 0xEAAC88E5DC99}, //16
-    {.a = 0x6B02733BB6EC, .b = 0x7038CD25C408}, //17
-    {.a = 0x403D706BA880, .b = 0xB39D19A280DF}, //18
-    {.a = 0xC11F4597EFB5, .b = 0x70D901648CB9}, //19
-    {.a = 0x0DB520C78C1C, .b = 0x73E5B9D9D3A4}, //20
-    {.a = 0x3EBCE0925B2F, .b = 0x372CC880F216}, //21
-    {.a = 0x16A27AF45407, .b = 0x9868925175BA}, //22
-    {.a = 0xABA208516740, .b = 0xCE26ECB95252}, //23
-    {.a = 0xCD64E567ABCD, .b = 0x8F79C4FD8A01}, //24
-    {.a = 0x764CD061F1E6, .b = 0xA74332F74994}, //25
-    {.a = 0x1CC219E9FEC1, .b = 0xB90DE525CEB6}, //26
-    {.a = 0x2FE3CB83EA43, .b = 0xFBA88F109B32}, //27
-    {.a = 0x07894FFEC1D6, .b = 0xEFCB0E689DB3}, //28
-    {.a = 0x04C297B91308, .b = 0xC8454C154CB5}, //29
-    {.a = 0x7A38E3511A38, .b = 0xAB16584C972A}, //30
-    {.a = 0x7545DF809202, .b = 0xECF751084A80}, //31
-    {.a = 0x5125974CD391, .b = 0xD3EAFB5DF46D}, //32
-    {.a = 0x7A86AA203788, .b = 0xE41242278CA2}, //33
-    {.a = 0xAFCEF64C9913, .b = 0x9DB96DCA4324}, //34
-    {.a = 0x04EAA462F70B, .b = 0xAC17B93E2FAE}, //35
-    {.a = 0xE734C210F27E, .b = 0x29BA8C3E9FDA}, //36
-    {.a = 0xD5524F591EED, .b = 0x5DAF42861B4D}, //37
-    {.a = 0xE4821A377B75, .b = 0xE8709E486465}, //38
-    {.a = 0x518DC6EEA089, .b = 0x97C64AC98CA4}, //39
-    {.a = 0xBB52F8CCE07F, .b = 0x6B6119752C70}, //40
+const MfClassicKeyPair troika_4k_key[1] = {
+    {.a = 0xA73F5DC1D333},
 };
 
-const uint8_t SMARTRIDER_STANDARD_KEYS[3][6] = {
-    {0x20, 0x31, 0xD1, 0xE5, 0x7A, 0x3B},
-    {0x4C, 0xA6, 0x02, 0x9F, 0x94, 0x73},
-    {0x19, 0x19, 0x53, 0x98, 0xE3, 0x2F}};
-
-const MfClassicKeyPair charliecard_1k_keys[16] = {
-    {.a = 0x3060206F5B0A, .b = 0xF1B9F5669CC8},
-    {.a = 0x5EC39B022F2B, .b = 0xF662248E7E89},
-    {.a = 0x5EC39B022F2B, .b = 0xF662248E7E89},
-    {.a = 0x5EC39B022F2B, .b = 0xF662248E7E89},
-    {.a = 0x5EC39B022F2B, .b = 0xF662248E7E89},
-    {.a = 0x5EC39B022F2B, .b = 0xF662248E7E89},
-    {.a = 0x5EC39B022F2B, .b = 0xF662248E7E89},
-    {.a = 0x5EC39B022F2B, .b = 0xF662248E7E89},
-    {.a = 0x3A09594C8587, .b = 0x62387B8D250D},
-    {.a = 0xF238D78FF48F, .b = 0x9DC282D46217},
-    {.a = 0xAFD0BA94D624, .b = 0x92EE4DC87191},
-    {.a = 0xB35A0E4ACC09, .b = 0x756EF55E2507},
-    {.a = 0x447AB7FD5A6B, .b = 0x932B9CB730EF},
-    {.a = 0x1F1A0A111B5B, .b = 0xAD9E0A1CA2F7},
-    {.a = 0xD58023BA2BDC, .b = 0x62CED42A6D87},
-    {.a = 0x2548A443DF28, .b = 0x2ED3B15E7C0F},
+const MfClassicKeyPair smartrider_verify_key[] = {
+    {.a = 0x2031D1E57A3B},
 };
 
-const MfClassicKeyPair bip_1k_keys[16] = {
-    {.a = 0x3a42f33af429, .b = 0x1fc235ac1309},
-    {.a = 0x6338a371c0ed, .b = 0x243f160918d1},
-    {.a = 0xf124c2578ad0, .b = 0x9afc42372af1},
-    {.a = 0x32ac3b90ac13, .b = 0x682d401abb09},
-    {.a = 0x4ad1e273eaf1, .b = 0x067db45454a9},
-    {.a = 0xe2c42591368a, .b = 0x15fc4c7613fe},
-    {.a = 0x2a3c347a1200, .b = 0x68d30288910a},
-    {.a = 0x16f3d5ab1139, .b = 0xf59a36a2546d},
-    {.a = 0x937a4fff3011, .b = 0x64e3c10394c2},
-    {.a = 0x35c3d2caee88, .b = 0xb736412614af},
-    {.a = 0x693143f10368, .b = 0x324f5df65310},
-    {.a = 0xa3f97428dd01, .b = 0x643fb6de2217},
-    {.a = 0x63f17a449af0, .b = 0x82f435dedf01},
-    {.a = 0xc4652c54261c, .b = 0x0263de1278f3},
-    {.a = 0xd49e2826664f, .b = 0x51284c3686a6},
-    {.a = 0x3df14c8000a1, .b = 0x6a470d54127c},
+const MfClassicKeyPair charliecard_1k_verify_key[] = {
+    {.a = 0x5EC39B022F2B},
 };
 
-const MfClassicKeyPair metromoney_1k_keys[16] = {
-    {.a = 0x2803BCB0C7E1, .b = 0x4FA9EB49F75E},
-    {.a = 0x9C616585E26D, .b = 0xD1C71E590D16},
-    {.a = 0x9C616585E26D, .b = 0xA160FCD5EC4C},
-    {.a = 0x9C616585E26D, .b = 0xA160FCD5EC4C},
-    {.a = 0x9C616585E26D, .b = 0xA160FCD5EC4C},
-    {.a = 0x9C616585E26D, .b = 0xA160FCD5EC4C},
-    {.a = 0xFFFFFFFFFFFF, .b = 0xFFFFFFFFFFFF},
-    {.a = 0xFFFFFFFFFFFF, .b = 0xFFFFFFFFFFFF},
-    {.a = 0x112233445566, .b = 0x361A62F35BC9},
-    {.a = 0x112233445566, .b = 0x361A62F35BC9},
-    {.a = 0xFFFFFFFFFFFF, .b = 0xFFFFFFFFFFFF},
-    {.a = 0xFFFFFFFFFFFF, .b = 0xFFFFFFFFFFFF},
-    {.a = 0xFFFFFFFFFFFF, .b = 0xFFFFFFFFFFFF},
-    {.a = 0xFFFFFFFFFFFF, .b = 0xFFFFFFFFFFFF},
-    {.a = 0xFFFFFFFFFFFF, .b = 0xFFFFFFFFFFFF},
-    {.a = 0xFFFFFFFFFFFF, .b = 0xFFFFFFFFFFFF},
+const MfClassicKeyPair bip_1k_verify_key[1] = {
+    {.a = 0x3a42f33af429},
+};
+
+const MfClassicKeyPair metromoney_1k_verify_key[1] = {
+    {.a = 0x9C616585E26D},
 };
 
 static bool charliecard_verify(Nfc* nfc) {
@@ -143,8 +43,7 @@ static bool charliecard_verify(Nfc* nfc) {
         FURI_LOG_I(TAG, "Verifying sector %u", verify_sector);
 
         MfClassicKey key = {0};
-        bit_lib_num_to_bytes_be(
-            charliecard_1k_keys[verify_sector].a, COUNT_OF(key.data), key.data);
+        bit_lib_num_to_bytes_be(charliecard_1k_verify_key[0].a, COUNT_OF(key.data), key.data);
 
         MfClassicAuthContext auth_context;
         MfClassicError error =
@@ -169,7 +68,7 @@ bool bip_verify(Nfc* nfc) {
         FURI_LOG_I(TAG, "Verifying sector %u", verify_sector);
 
         MfClassicKey key = {};
-        bit_lib_num_to_bytes_be(bip_1k_keys[0].a, COUNT_OF(key.data), key.data);
+        bit_lib_num_to_bytes_be(bip_1k_verify_key[0].a, COUNT_OF(key.data), key.data);
 
         MfClassicAuthContext auth_ctx = {};
         MfClassicError error =
@@ -196,8 +95,7 @@ static bool metromoney_verify(Nfc* nfc) {
         FURI_LOG_D(TAG, "Verifying sector %u", ticket_sector_number);
 
         MfClassicKey key = {0};
-        bit_lib_num_to_bytes_be(
-            metromoney_1k_keys[ticket_sector_number].a, COUNT_OF(key.data), key.data);
+        bit_lib_num_to_bytes_be(metromoney_1k_verify_key[0].a, COUNT_OF(key.data), key.data);
 
         MfClassicAuthContext auth_context;
         MfClassicError error = mf_classic_poller_sync_auth(
@@ -213,49 +111,28 @@ static bool metromoney_verify(Nfc* nfc) {
     return verified;
 }
 
-static bool smartrider_authenticate_and_read(
-    Nfc* nfc,
-    uint8_t sector,
-    const uint8_t* key,
-    MfClassicKeyType key_type,
-    MfClassicBlock* block_data) {
-    MfClassicKey mf_key;
-    memcpy(mf_key.data, key, 6);
-    uint8_t block = mf_classic_get_first_block_num_of_sector(sector);
-
-    if(mf_classic_poller_sync_auth(nfc, block, &mf_key, key_type, NULL) != MfClassicErrorNone) {
-        FURI_LOG_D(TAG, "Authentication failed for sector %d key type %d", sector, key_type);
-        return false;
-    }
+static bool smartrider_verify(Nfc* nfc) {
+    bool verified = false;
 
-    if(mf_classic_poller_sync_read_block(nfc, block, &mf_key, key_type, block_data) !=
-       MfClassicErrorNone) {
-        FURI_LOG_D(TAG, "Read failed for sector %d", sector);
-        return false;
-    }
+    do {
+        const uint8_t block_number = mf_classic_get_first_block_num_of_sector(0) + 1;
+        FURI_LOG_D(TAG, "Verifying sector 0");
 
-    return true;
-}
+        MfClassicKey key = {0};
+        bit_lib_num_to_bytes_be(smartrider_verify_key[0].a, COUNT_OF(key.data), key.data);
 
-static bool smartrider_verify(Nfc* nfc) {
-    furi_assert(nfc);
-    MfClassicBlock block_data;
-
-    for(int i = 0; i < 3; i++) {
-        if(!smartrider_authenticate_and_read(
-               nfc,
-               i * 6,
-               SMARTRIDER_STANDARD_KEYS[i],
-               i % 2 == 0 ? MfClassicKeyTypeA : MfClassicKeyTypeB,
-               &block_data) ||
-           memcmp(block_data.data, SMARTRIDER_STANDARD_KEYS[i], 6) != 0) {
-            FURI_LOG_D(TAG, "Authentication or key mismatch for key %d", i);
-            return false;
+        MfClassicAuthContext auth_context;
+        MfClassicError error =
+            mf_classic_poller_sync_auth(nfc, block_number, &key, MfClassicKeyTypeA, &auth_context);
+        if(error != MfClassicErrorNone) {
+            FURI_LOG_D(TAG, "Failed to read block %u: %d", block_number, error);
+            break;
         }
-    }
 
-    FURI_LOG_I(TAG, "SmartRider card verified");
-    return true;
+        verified = true;
+    } while(false);
+
+    return verified;
 }
 
 static bool troika_get_card_config(TroikaCardConfig* config, MfClassicType type) {
@@ -263,10 +140,10 @@ static bool troika_get_card_config(TroikaCardConfig* config, MfClassicType type)
 
     if(type == MfClassicType1k) {
         config->data_sector = 11;
-        config->keys = troika_1k_keys;
+        config->keys = troika_1k_key;
     } else if(type == MfClassicType4k) {
         config->data_sector = 8; // Further testing needed
-        config->keys = troika_4k_keys;
+        config->keys = troika_4k_key;
     } else {
         success = false;
     }
@@ -285,7 +162,7 @@ static bool troika_verify_type(Nfc* nfc, MfClassicType type) {
         FURI_LOG_D(TAG, "Verifying sector %lu", cfg.data_sector);
 
         MfClassicKey key = {0};
-        bit_lib_num_to_bytes_be(cfg.keys[cfg.data_sector].a, COUNT_OF(key.data), key.data);
+        bit_lib_num_to_bytes_be(cfg.keys[0].a, COUNT_OF(key.data), key.data);
 
         MfClassicAuthContext auth_context;
         MfClassicError error =

+ 1 - 0
scenes/metroflip_scene_about.c

@@ -1,5 +1,6 @@
 #include "../metroflip_i.h"
 #include <dolphin/dolphin.h>
+#include "../api/metroflip/metroflip_api.h"
 
 #define TAG "Metroflip:Scene:About"
 

+ 18 - 10
scenes/metroflip_scene_auto.c

@@ -10,7 +10,7 @@
 #include "desfire.h"
 #include <nfc/protocols/mf_desfire/mf_desfire_poller.h>
 #include <lib/nfc/protocols/mf_desfire/mf_desfire.h>
-
+#include "../api/metroflip/metroflip_api.h"
 #define TAG "Metroflip:Scene:Auto"
 
 static NfcCommand
@@ -124,20 +124,23 @@ bool metroflip_scene_auto_on_event(void* context, SceneManagerEvent event) {
             nfc_poller_stop(app->poller);
             nfc_poller_free(app->poller);
             if(app->desfire_card_type == CARD_TYPE_CLIPPER) {
-                scene_manager_next_scene(app->scene_manager, MetroflipSceneClipper);
+                strncpy(app->card_type, "clipper", sizeof(app->card_type) - 1);
             } else if(app->desfire_card_type == CARD_TYPE_OPAL) {
-                scene_manager_next_scene(app->scene_manager, MetroflipSceneOpal);
+                strncpy(app->card_type, "opal", sizeof(app->card_type) - 1);
             } else if(app->desfire_card_type == CARD_TYPE_MYKI) {
-                scene_manager_next_scene(app->scene_manager, MetroflipSceneClipper);
+                strncpy(app->card_type, "myki", sizeof(app->card_type) - 1);
             } else if(app->desfire_card_type == CARD_TYPE_ITSO) {
-                scene_manager_next_scene(app->scene_manager, MetroflipSceneItso);
+                strncpy(app->card_type, "itso", sizeof(app->card_type) - 1);
             } else if(app->desfire_card_type == CARD_TYPE_DESFIRE_UNKNOWN) {
+                strncpy(app->card_type, "unknown", sizeof(app->card_type) - 1);
                 Popup* popup = app->popup;
                 popup_set_header(popup, "Unsupported\n card", 58, 31, AlignLeft, AlignTop);
             } else {
+                strncpy(app->card_type, "unknown", sizeof(app->card_type) - 1);
                 Popup* popup = app->popup;
                 popup_set_header(popup, "Unsupported\n card", 58, 31, AlignLeft, AlignTop);
             }
+            scene_manager_next_scene(app->scene_manager, MetroflipSceneParse);
             consumed = true;
         } else if(event.event == MetroflipCustomEventCardLost) {
             Popup* popup = app->popup;
@@ -163,34 +166,38 @@ bool metroflip_scene_auto_on_event(void* context, SceneManagerEvent event) {
                 UNUSED(popup);
                 switch(card_type) {
                 case CARD_TYPE_METROMONEY:
+                    strncpy(app->card_type, "metromoney", sizeof(app->card_type) - 1);
                     FURI_LOG_I(TAG, "Detected: Metromoney\n");
-                    scene_manager_next_scene(app->scene_manager, MetroflipSceneMetromoney);
                     break;
                 case CARD_TYPE_CHARLIECARD:
+                    strncpy(app->card_type, "charliecard", sizeof(app->card_type) - 1);
                     FURI_LOG_I(TAG, "Detected: CharlieCard\n");
-                    scene_manager_next_scene(app->scene_manager, MetroflipSceneCharlieCard);
                     break;
                 case CARD_TYPE_SMARTRIDER:
+                    strncpy(app->card_type, "smartrider", sizeof(app->card_type) - 1);
                     FURI_LOG_I(TAG, "Detected: SmartRider\n");
-                    scene_manager_next_scene(app->scene_manager, MetroflipSceneSmartrider);
                     break;
                 case CARD_TYPE_TROIKA:
+                    strncpy(app->card_type, "troika", sizeof(app->card_type) - 1);
                     FURI_LOG_I(TAG, "Detected: Troika\n");
-                    scene_manager_next_scene(app->scene_manager, MetroflipSceneTroika);
                     break;
                 case CARD_TYPE_UNKNOWN:
+                    strncpy(app->card_type, "unknown", sizeof(app->card_type) - 1);
                     popup_set_header(popup, "Unsupported\n card", 58, 31, AlignLeft, AlignTop);
                     break;
                 default:
+                    strncpy(app->card_type, "unknown", sizeof(app->card_type) - 1);
                     FURI_LOG_I(TAG, "Detected: Unknown card type\n");
                     popup_set_header(popup, "Unsupported\n card", 58, 31, AlignLeft, AlignTop);
                     break;
                 }
+                scene_manager_next_scene(app->scene_manager, MetroflipSceneParse);
                 consumed = true;
             } else if(
                 nfc_detected_protocols_get_protocol(app->detected_protocols, 0) ==
                 NfcProtocolIso14443_4b) {
-                scene_manager_next_scene(app->scene_manager, MetroflipSceneCalypso);
+                strncpy(app->card_type, "calypso", sizeof(app->card_type) - 1);
+                scene_manager_next_scene(app->scene_manager, MetroflipSceneParse);
                 consumed = true;
             } else if(
                 nfc_detected_protocols_get_protocol(app->detected_protocols, 0) ==
@@ -201,6 +208,7 @@ bool metroflip_scene_auto_on_event(void* context, SceneManagerEvent event) {
             } else if(
                 nfc_detected_protocols_get_protocol(app->detected_protocols, 0) ==
                 NfcProtocolInvalid) {
+                strncpy(app->card_type, "unknown", sizeof(app->card_type) - 1);
                 Popup* popup = app->popup;
                 popup_set_header(
                     popup, "protocol\n currently\n unsupported", 58, 31, AlignLeft, AlignTop);

+ 0 - 11
scenes/metroflip_scene_calypso.h

@@ -1,11 +0,0 @@
-#include <gui/gui.h>
-#include <gui/modules/widget_elements/widget_element.h>
-#include "../api/calypso/transit/navigo.h"
-#include "../api/calypso/transit/opus.h"
-#include "../api/calypso/transit/ravkav.h"
-#include "../api/calypso/calypso_i.h"
-#include <datetime.h>
-#include <stdbool.h>
-
-void metroflip_back_button_widget_callback(GuiButtonType result, InputType type, void* context);
-void metroflip_next_button_widget_callback(GuiButtonType result, InputType type, void* context);

+ 1 - 11
scenes/metroflip_scene_config.h

@@ -1,16 +1,6 @@
 ADD_SCENE(metroflip, start, Start)
 ADD_SCENE(metroflip, auto, Auto)
-ADD_SCENE(metroflip, calypso, Calypso)
-ADD_SCENE(metroflip, charliecard, CharlieCard)
-ADD_SCENE(metroflip, clipper, Clipper)
-ADD_SCENE(metroflip, metromoney, Metromoney)
-ADD_SCENE(metroflip, smartrider, Smartrider)
-ADD_SCENE(metroflip, read_success, ReadSuccess)
+ADD_SCENE(metroflip, parse, Parse)
 ADD_SCENE(metroflip, ovc, OVC)
-ADD_SCENE(metroflip, bip, Bip)
-ADD_SCENE(metroflip, myki, Myki)
-ADD_SCENE(metroflip, troika, Troika)
-ADD_SCENE(metroflip, opal, Opal)
-ADD_SCENE(metroflip, itso, Itso)
 ADD_SCENE(metroflip, about, About)
 ADD_SCENE(metroflip, credits, Credits)

+ 1 - 0
scenes/metroflip_scene_credits.c

@@ -1,5 +1,6 @@
 #include "../metroflip_i.h"
 #include <dolphin/dolphin.h>
+#include "../api/metroflip/metroflip_api.h"
 
 #define TAG "Metroflip:Scene:Credits"
 

+ 1 - 3
scenes/metroflip_scene_ovc.c

@@ -5,6 +5,7 @@
 #include <nfc/protocols/mf_classic/mf_classic_poller_sync.h>
 #include <nfc/protocols/mf_classic/mf_classic.h>
 #include <nfc/protocols/mf_classic/mf_classic_poller.h>
+#include "../api/metroflip/metroflip_api.h"
 
 #include <dolphin/dolphin.h>
 #include <bit_lib.h>
@@ -279,9 +280,6 @@ bool metroflip_scene_ovc_on_event(void* context, SceneManagerEvent event) {
             Popup* popup = app->popup;
             popup_set_header(popup, "Failed", 68, 30, AlignLeft, AlignTop);
             consumed = true;
-        } else if(event.event == MetroflipCustomEventPollerSuccess) {
-            scene_manager_next_scene(app->scene_manager, MetroflipSceneReadSuccess);
-            consumed = true;
         }
     } else if(event.type == SceneManagerEventTypeBack) {
         scene_manager_search_and_switch_to_previous_scene(app->scene_manager, MetroflipSceneStart);

+ 68 - 0
scenes/metroflip_scene_parse.c

@@ -0,0 +1,68 @@
+#include "../metroflip_i.h"
+#include <furi.h>
+#include "../metroflip_plugins.h"
+#define TAG "Metroflip:Scene:Parse"
+#include <stdio.h>
+
+void metroflip_scene_parse_on_enter(void* context) {
+    Metroflip* app = context;
+    metroflip_plugin_manager_alloc(app);
+
+    // Check if card_type is empty or unknown
+    if(app->card_type[0] == '\0') {
+        view_dispatcher_send_custom_event(app->view_dispatcher, MetroflipCustomEventWrongCard);
+    } else if(strcmp(app->card_type, "unknown") == 0) {
+        view_dispatcher_send_custom_event(app->view_dispatcher, MetroflipCustomEventWrongCard);
+    } else {
+        char plugin_name[64]; // Adjust size as needed
+        snprintf(plugin_name, sizeof(plugin_name), "%s_plugin.fal", app->card_type);
+
+        // Construct the final path
+        char path[128]; // Adjust for path length
+        snprintf(path, sizeof(path), "/ext/apps_assets/metroflip/plugins/%s", plugin_name);
+        FURI_LOG_I(TAG, "path %s", path);
+
+        // Try loading the plugin
+        if(plugin_manager_load_single(app->plugin_manager, path) != PluginManagerErrorNone) {
+            FURI_LOG_E(TAG, "Failed to load parse plugin");
+            return;
+        }
+
+        // Get and run the plugin's on_enter function
+        const MetroflipPlugin* plugin = plugin_manager_get_ep(app->plugin_manager, 0);
+        plugin->plugin_on_enter(app);
+    }
+}
+
+bool metroflip_scene_parse_on_event(void* context, SceneManagerEvent event) {
+    Metroflip* app = context;
+    if(event.type == SceneManagerEventTypeCustom) {
+        if(event.event == MetroflipCustomEventWrongCard) {
+            Popup* popup = app->popup;
+            popup_set_header(popup, "card\n currently\n unsupported", 58, 31, AlignLeft, AlignTop);
+            popup_set_icon(popup, 0, 3, &I_RFIDDolphinReceive_97x61);
+            return true;
+        }
+    } else if(event.type == SceneManagerEventTypeBack) {
+        scene_manager_search_and_switch_to_previous_scene(app->scene_manager, MetroflipSceneStart);
+        return true;
+    }
+
+    // Get and run the plugin's on_event function
+    const MetroflipPlugin* plugin = plugin_manager_get_ep(app->plugin_manager, 0);
+    return plugin->plugin_on_event(app, event);
+}
+
+void metroflip_scene_parse_on_exit(void* context) {
+    Metroflip* app = context;
+
+    if(!((app->card_type[0] == '\0') || (strcmp(app->card_type, "unknown") == 0))) {
+        // Get and run the plugin's on_exit function
+        const MetroflipPlugin* plugin = plugin_manager_get_ep(app->plugin_manager, 0);
+        plugin->plugin_on_exit(app);
+
+        plugin_manager_free(app->plugin_manager);
+        composite_api_resolver_free(app->resolver);
+    }
+    memset(app->card_type, 0, sizeof(app->card_type));
+}

+ 0 - 56
scenes/metroflip_scene_read_success.c

@@ -1,56 +0,0 @@
-#include "../metroflip_i.h"
-#include <dolphin/dolphin.h>
-
-#define TAG "Metroflip:Scene:ReadSuccess"
-
-void metroflip_scene_read_success_on_enter(void* context) {
-    Metroflip* app = context;
-    Widget* widget = app->widget;
-
-    dolphin_deed(DolphinDeedNfcReadSuccess);
-    furi_string_reset(app->text_box_store);
-
-    FuriString* str = furi_string_alloc();
-
-    if(strcmp(app->card_type, "Metromoney") == 0) {
-        FURI_LOG_I(TAG, "Metromoney card detected");
-        furi_string_printf(
-            str,
-            "\e#Metromoney\nCard number: %lu\nBalance: %lu.%02u GEL",
-            app->card_number,
-            app->balance_lari,
-            app->balance_tetri);
-    } else {
-        FURI_LOG_I(TAG, "Unknown card type");
-        furi_string_printf(str, "\e#Unknown card\n");
-    }
-
-    widget_add_text_scroll_element(widget, 0, 0, 128, 64, furi_string_get_cstr(str));
-
-    widget_add_button_element(
-        widget, GuiButtonTypeRight, "Exit", metroflip_exit_widget_callback, app);
-
-    furi_string_free(str);
-    view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewWidget);
-}
-
-bool metroflip_scene_read_success_on_event(void* context, SceneManagerEvent event) {
-    Metroflip* app = context;
-    bool consumed = false;
-
-    if(event.type == SceneManagerEventTypeCustom) {
-        if(event.event == GuiButtonTypeLeft) {
-            consumed = scene_manager_previous_scene(app->scene_manager);
-        }
-    } else if(event.type == SceneManagerEventTypeBack) {
-        scene_manager_search_and_switch_to_previous_scene(app->scene_manager, MetroflipSceneStart);
-        consumed = true;
-    }
-    return consumed;
-}
-
-void metroflip_scene_read_success_on_exit(void* context) {
-    Metroflip* app = context;
-    widget_reset(app->widget);
-    UNUSED(context);
-}

+ 48 - 9
scenes/metroflip_scene_bip.c → scenes/plugins/bip.c

@@ -17,7 +17,7 @@
  * along with this program.  If not, see <http://www.gnu.org/licenses/>.
  */
 #include <flipper_application.h>
-#include "../metroflip_i.h"
+#include "../../metroflip_i.h"
 
 #include <nfc/protocols/mf_classic/mf_classic_poller_sync.h>
 #include <nfc/protocols/mf_classic/mf_classic.h>
@@ -30,6 +30,8 @@
 #include <nfc/nfc_device.h>
 #include <nfc/nfc_listener.h>
 #include <locale/locale.h>
+#include "../../api/metroflip/metroflip_api.h"
+#include "../../metroflip_plugins.h"
 
 #define TAG "Metroflip:Scene:Bip"
 
@@ -39,6 +41,25 @@
 #define BIP_LAST_TOP_UPS_SECTOR_NUMBER     (10)
 #define BIP_TRIPS_INFO_SECTOR_NUMBER       (11)
 
+const MfClassicKeyPair bip_1k_keys[16] = {
+    {.a = 0x3a42f33af429, .b = 0x1fc235ac1309},
+    {.a = 0x6338a371c0ed, .b = 0x243f160918d1},
+    {.a = 0xf124c2578ad0, .b = 0x9afc42372af1},
+    {.a = 0x32ac3b90ac13, .b = 0x682d401abb09},
+    {.a = 0x4ad1e273eaf1, .b = 0x067db45454a9},
+    {.a = 0xe2c42591368a, .b = 0x15fc4c7613fe},
+    {.a = 0x2a3c347a1200, .b = 0x68d30288910a},
+    {.a = 0x16f3d5ab1139, .b = 0xf59a36a2546d},
+    {.a = 0x937a4fff3011, .b = 0x64e3c10394c2},
+    {.a = 0x35c3d2caee88, .b = 0xb736412614af},
+    {.a = 0x693143f10368, .b = 0x324f5df65310},
+    {.a = 0xa3f97428dd01, .b = 0x643fb6de2217},
+    {.a = 0x63f17a449af0, .b = 0x82f435dedf01},
+    {.a = 0xc4652c54261c, .b = 0x0263de1278f3},
+    {.a = 0xd49e2826664f, .b = 0x51284c3686a6},
+    {.a = 0x3df14c8000a1, .b = 0x6a470d54127c},
+};
+
 typedef struct {
     DateTime datetime;
     uint16_t amount;
@@ -248,7 +269,7 @@ static bool
     return parsed;
 }
 
-static NfcCommand metroflip_scene_bip_poller_callback(NfcGenericEvent event, void* context) {
+static NfcCommand bip_poller_callback(NfcGenericEvent event, void* context) {
     furi_assert(context);
     furi_assert(event.event_data);
     furi_assert(event.protocol == NfcProtocolMfClassic);
@@ -313,8 +334,7 @@ static NfcCommand metroflip_scene_bip_poller_callback(NfcGenericEvent event, voi
     return command;
 }
 
-void metroflip_scene_bip_on_enter(void* context) {
-    Metroflip* app = context;
+static void bip_on_enter(Metroflip* app) {
     dolphin_deed(DolphinDeedNfcRead);
 
     app->sec_num = 0;
@@ -328,13 +348,12 @@ void metroflip_scene_bip_on_enter(void* context) {
     view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewPopup);
     nfc_scanner_alloc(app->nfc);
     app->poller = nfc_poller_alloc(app->nfc, NfcProtocolMfClassic);
-    nfc_poller_start(app->poller, metroflip_scene_bip_poller_callback, app);
+    nfc_poller_start(app->poller, bip_poller_callback, app);
 
     metroflip_app_blink_start(app);
 }
 
-bool metroflip_scene_bip_on_event(void* context, SceneManagerEvent event) {
-    Metroflip* app = context;
+static bool bip_on_event(Metroflip* app, SceneManagerEvent event) {
     bool consumed = false;
 
     if(event.type == SceneManagerEventTypeCustom) {
@@ -363,8 +382,7 @@ bool metroflip_scene_bip_on_event(void* context, SceneManagerEvent event) {
     return consumed;
 }
 
-void metroflip_scene_bip_on_exit(void* context) {
-    Metroflip* app = context;
+static void bip_on_exit(Metroflip* app) {
     widget_reset(app->widget);
 
     if(app->poller) {
@@ -377,3 +395,24 @@ void metroflip_scene_bip_on_exit(void* context) {
 
     metroflip_app_blink_stop(app);
 }
+
+/* Actual implementation of app<>plugin interface */
+static const MetroflipPlugin bip_plugin = {
+    .card_name = "Bip",
+    .plugin_on_enter = bip_on_enter,
+    .plugin_on_event = bip_on_event,
+    .plugin_on_exit = bip_on_exit,
+
+};
+
+/* Plugin descriptor to comply with basic plugin specification */
+static const FlipperAppPluginDescriptor bip_plugin_descriptor = {
+    .appid = METROFLIP_SUPPORTED_CARD_PLUGIN_APP_ID,
+    .ep_api_version = METROFLIP_SUPPORTED_CARD_PLUGIN_API_VERSION,
+    .entry_point = &bip_plugin,
+};
+
+/* Plugin entry point - must return a pointer to const descriptor  */
+const FlipperAppPluginDescriptor* bip_plugin_ep(void) {
+    return &bip_plugin_descriptor;
+}

+ 30 - 11
scenes/metroflip_scene_calypso.c → scenes/plugins/calypso.c

@@ -1,9 +1,11 @@
-#include "metroflip_scene_calypso.h"
-#include "../metroflip_i.h"
+#include "../../api/metroflip/metroflip_api.h"
+#include "../../metroflip_i.h"
 #include <datetime.h>
 #include <dolphin/dolphin.h>
 #include <notification/notification_messages.h>
 #include <locale/locale.h>
+#include "../../api/metroflip/metroflip_api.h"
+#include "../../metroflip_plugins.h"
 
 #include <nfc/protocols/iso14443_4b/iso14443_4b_poller.h>
 
@@ -382,7 +384,7 @@ void delay(int milliseconds) {
     furi_thread_flags_wait(0, FuriFlagWaitAny, milliseconds);
 }
 
-static NfcCommand metroflip_scene_calypso_poller_callback(NfcGenericEvent event, void* context) {
+static NfcCommand calypso_poller_callback(NfcGenericEvent event, void* context) {
     furi_assert(event.protocol == NfcProtocolIso14443_4b);
     NfcCommand next_command = NfcCommandContinue;
     MetroflipPollerEventType stage = MetroflipPollerEventTypeStart;
@@ -2439,8 +2441,7 @@ static NfcCommand metroflip_scene_calypso_poller_callback(NfcGenericEvent event,
     return next_command;
 }
 
-void metroflip_scene_calypso_on_enter(void* context) {
-    Metroflip* app = context;
+static void calypso_on_enter(Metroflip* app) {
     dolphin_deed(DolphinDeedNfcRead);
 
     // Setup view
@@ -2452,13 +2453,12 @@ void metroflip_scene_calypso_on_enter(void* context) {
     view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewPopup);
     nfc_scanner_alloc(app->nfc);
     app->poller = nfc_poller_alloc(app->nfc, NfcProtocolIso14443_4b);
-    nfc_poller_start(app->poller, metroflip_scene_calypso_poller_callback, app);
+    nfc_poller_start(app->poller, calypso_poller_callback, app);
 
     metroflip_app_blink_start(app);
 }
 
-bool metroflip_scene_calypso_on_event(void* context, SceneManagerEvent event) {
-    Metroflip* app = context;
+static bool calypso_on_event(Metroflip* app, SceneManagerEvent event) {
     bool consumed = false;
 
     if(event.type == SceneManagerEventTypeCustom) {
@@ -2483,9 +2483,7 @@ bool metroflip_scene_calypso_on_event(void* context, SceneManagerEvent event) {
     return consumed;
 }
 
-void metroflip_scene_calypso_on_exit(void* context) {
-    Metroflip* app = context;
-
+static void calypso_on_exit(Metroflip* app) {
     if(app->poller) {
         nfc_poller_stop(app->poller);
         nfc_poller_free(app->poller);
@@ -2506,3 +2504,24 @@ void metroflip_scene_calypso_on_exit(void* context) {
         app->calypso_context = NULL;
     }
 }
+
+/* Actual implementation of app<>plugin interface */
+static const MetroflipPlugin calypso_plugin = {
+    .card_name = "Calypso",
+    .plugin_on_enter = calypso_on_enter,
+    .plugin_on_event = calypso_on_event,
+    .plugin_on_exit = calypso_on_exit,
+
+};
+
+/* Plugin descriptor to comply with basic plugin specification */
+static const FlipperAppPluginDescriptor calypso_plugin_descriptor = {
+    .appid = METROFLIP_SUPPORTED_CARD_PLUGIN_APP_ID,
+    .ep_api_version = METROFLIP_SUPPORTED_CARD_PLUGIN_API_VERSION,
+    .entry_point = &calypso_plugin,
+};
+
+/* Plugin entry point - must return a pointer to const descriptor  */
+const FlipperAppPluginDescriptor* calypso_plugin_ep(void) {
+    return &calypso_plugin_descriptor;
+}

+ 1331 - 1287
scenes/metroflip_scene_charliecard.c → scenes/plugins/charliecard.c

@@ -1,1287 +1,1331 @@
-/*
- * Parser for MBTA CharlieCard (Boston, MA, USA).
- *
- * Copyright 2024 Zachary Weiss <me@zachary.ws>
- * 
- * Public security research on the MBTA's fare system stretches back to 2008,
- * starting with Russel Ryan, Zack Anderson, and Alessandro Chiesa's 
- * "Anatomy of a Subway Hack", for which they were famously issued a gag order. 
- * A thorough history of research & researchers deserving of credit is 
- * detailed by @bobbyrsec in his 2022 blog post (& presentation):
- * "Operation Charlie: Hacking the MBTA CharlieCard from 2008 to Present" 
- * https://medium.com/@bobbyrsec/operation-charlie-hacking-the-mbta-charliecard-from-2008-to-present-24ea9f0aaa38
- * 
- * Fare gate IDs, card types, and general assistance courtesy of the
- * minds behind DEFCON 31's "Boston Infinite Money Glitch" presentation:
- * — Matthew Harris; mattyharris.net <matty@mattyharris.net>
- * — Zachary Bertocchi; zackbertocchi.com <zach@zachbertocci.com>
- * — Scott Campbell; josephscottcampbell.com <scott@josephscottcampbell.com>
- * — Noah Gibson; <noahgibson06@proton.me>
- * Talk available at: https://www.youtube.com/watch?v=1JT_lTfK69Q
- * 
- * TODOs:
- * — Reverse engineer passes (sectors 4 & 5?), impl.
- * — Infer transaction flag meanings
- * — Infer remaining unknown bytes in the balance sectors (2 & 3)
- * — Improve string output formatting, esp. of transaction log
- * — Mapping of buses to garages, and subsequently, route subsets via 
- *   http://roster.transithistory.org/ data
- * — Mapping of stations to lines
- * — Add'l data fields for side of station fare gates are on? Some stations
- *   separate inbound & outbound sides, so direction could be inferred
- *   from gates used.
- * — Continually gather data on fare gate ID mappings, update as collected;
- *   check locations this might be scrapable / inferrable from:
- *   [X] MBTA GTFS spec (https://www.mbta.com/developers/gtfs) features & IDs 
- *       seem too-coarse-grained & uncorrelated
- *   [X] MBTA ArcGIS (https://mbta-massdot.opendata.arcgis.com/) & Tableau 
- *       (https://public.tableau.com/app/profile/mbta.office.of.performance.management.and.innovation/vizzes) 
- *       files don't seem to have anything of that resolution (only down to ridership by station)
- *   [X] (skim of) MBTA public GitHub (https://github.com/mbta) repos make no reference to fare-gate-level data
- *   [X] (skim of) MBTA public engineering docs (https://www.mbta.com/engineering) unfruitful;
- *       Closest mention spotted is 2014 "Ridership and Service Statistics" 
- *       (https://cdn.mbta.com/sites/default/files/fmcb-meeting-docs/reports-policies/2014-07-mbta-bluebook-ed14.pdf)
- *       where on pg.40, "Equipment at Stations" is enumerated, and fare gates counts are given,
- *       listed as "AFC Gates" (presumably standing for "Automated Fare Collection")
- *   [X] Josiah Zachery criminal trial public evidence — convicted partially on 
- *       data on his CharlieCard, appeals partially on basis of legality of this search.
- *       Prev. court case (gag order mentioned in preamble) leaked some data in the files
- *       entered into evidence. Seemingly did not happen here; fare gate IDs unmentioned,
- *       only ever the nature of stored/saved data and methods of retrieval.
- *       Appelate case dockets 2019-P-0401, SJC-12952, SJ-2017-0390 
- *       (https://www.ma-appellatecourts.org/party)
- *       Trial court indictment 04/02/2015, Case# 1584CR10265 @Suffolk County Criminal Superior Court 
- *       (https://www.masscourts.org/eservices/home.page.16)
- *   [ ] FOIA / public records request? 
- *       (https://massachusettsdot.mycusthelp.com/WEBAPP/_rs/(S(tbcygdlm0oojy35p1wv0y2y5))/supporthome.aspx)
- *   [X] MBTA data blog? (https://www.massdottracker.com/datablog/)
- *   [ ] MassDOT developers Google group? (https://groups.google.com/g/massdotdevelopers)
- *       [X] preexisting posts
- *       [ ] ask directly?
- *   [ ] Other?
- *
- * 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 <http://www.gnu.org/licenses/>.
- */
-
-#include <flipper_application.h>
-#include "../metroflip_i.h"
-
-#include <nfc/protocols/mf_classic/mf_classic_poller_sync.h>
-#include <nfc/protocols/mf_classic/mf_classic.h>
-#include <nfc/protocols/mf_classic/mf_classic_poller.h>
-
-#include <dolphin/dolphin.h>
-#include <bit_lib.h>
-#include <datetime.h>
-#include <furi_hal.h>
-#include <locale/locale.h>
-#include <nfc/nfc.h>
-#include <nfc/nfc_device.h>
-#include <nfc/nfc_listener.h>
-
-#define TAG "Metroflip:Scene:CharlieCard"
-
-// starts Wednesday 2003/1/1 @ midnight
-#define CHARLIE_EPOCH          \
-    (DateTime) {               \
-        0, 0, 0, 1, 1, 2003, 4 \
-    }
-// timestep is one minute
-#define CHARLIE_TIME_DELTA_SECS       60
-#define CHARLIE_END_VALID_DELTA_SECS  60 * 8
-#define CHARLIE_N_TRANSACTION_HISTORY 10
-#define CHARLIE_N_PASSES              4
-
-// always from the same set of keys (cf. default keys dict for list w/o multiplicity)
-// we only care about the data in the first half of the sectors
-// second half sectors keys seemingly change position sometimes across cards?
-// no data stored there, but might want to impl some custom read function
-// accounting for this such that reading is faster (else it seems to fall back on dict
-// approach for remaining keys)...
-
-typedef struct {
-    uint16_t dollars;
-    uint8_t cents;
-} Money;
-
-#define FARE_BUS \
-    (Money) {    \
-        1, 70    \
-    }
-#define FARE_SUB \
-    (Money) {    \
-        2, 40    \
-    }
-
-typedef struct {
-    DateTime date;
-    uint16_t gate;
-    uint8_t g_flag;
-    Money fare;
-    uint16_t f_flag;
-} Transaction;
-
-typedef struct {
-    bool valid;
-    uint16_t pre;
-    uint16_t post;
-    DateTime date;
-} Pass;
-
-typedef struct {
-    uint16_t n_uses;
-    uint8_t active_balance_sector;
-} CounterSector;
-
-typedef struct {
-    Money balance;
-    uint16_t type;
-    DateTime issued;
-    DateTime end_validity;
-} BalanceSector;
-
-// IdMapping approach borrowed from Jeremy Cooper's 'clipper.c'
-typedef struct {
-    uint16_t id;
-    const char* name;
-} IdMapping;
-
-// this should be a complete accounting of types, (1 and 7 day pass types maybe missing?)
-static const IdMapping charliecard_types[] = {
-    // Regular card types
-    {.id = 367, .name = "Adult"},
-    {.id = 366, .name = "SV Adult"},
-    {.id = 418, .name = "Student"},
-    {.id = 419, .name = "Senior"},
-    {.id = 420, .name = "TAP"},
-    {.id = 417, .name = "Blind"},
-    {.id = 426, .name = "Child"},
-    {.id = 410, .name = "Employee ID Without Passback"},
-    {.id = 414, .name = "Employee ID With Passback"},
-    {.id = 415, .name = "Retiree"},
-    {.id = 416, .name = "Police/Fire"},
-
-    // Passes
-    {.id = 135, .name = "30 Day Local Bus Pass"},
-    {.id = 136, .name = "30 Day Inner Express Bus Pass"},
-    {.id = 137, .name = "30 Day Outer Express Bus Pass"},
-    {.id = 138, .name = "30 Day LinkPass"},
-    {.id = 139, .name = "30 Day Senior LinkPass"},
-    {.id = 148, .name = "30 Day TAP LinkPass"},
-    {.id = 150, .name = "Monthly Student LinkPass"},
-    {.id = 424, .name = "Monthly TAP LinkPass"},
-    {.id = 425, .name = "Monthly Senior LinkPass"},
-    {.id = 421, .name = "Senior TAP/Permit"},
-    {.id = 422, .name = "Senior TAP/Permit 30 Days"},
-
-    // Commuter rail passes
-    {.id = 166, .name = "30 Day Commuter Rail Zone 1A Pass"},
-    {.id = 167, .name = "30 Day Commuter Rail Zone 1 Pass"},
-    {.id = 168, .name = "30 Day Commuter Rail Zone 2 Pass"},
-    {.id = 169, .name = "30 Day Commuter Rail Zone 3 Pass"},
-    {.id = 170, .name = "30 Day Commuter Rail Zone 4 Pass"},
-    {.id = 171, .name = "30 Day Commuter Rail Zone 5 Pass"},
-    {.id = 172, .name = "30 Day Commuter Rail Zone 6 Pass"},
-    {.id = 173, .name = "30 Day Commuter Rail Zone 7 Pass"},
-    {.id = 174, .name = "30 Day Commuter Rail Zone 8 Pass"},
-    {.id = 175, .name = "30 Day Interzone 1 Pass"},
-    {.id = 176, .name = "30 Day Interzone 2 Pass"},
-    {.id = 177, .name = "30 Day Interzone 3 Pass"},
-    {.id = 178, .name = "30 Day Interzone 4 Pass"},
-    {.id = 179, .name = "30 Day Interzone 5 Pass"},
-    {.id = 180, .name = "30 Day Interzone 6 Pass"},
-    {.id = 181, .name = "30 Day Interzone 7 Pass"},
-    {.id = 182, .name = "30 Day Interzone 8 Pass"},
-
-    {.id = 140, .name = "One Way Interzone Adult 1 Zone"},
-    {.id = 141, .name = "One Way Interzone Adult 2 Zones"},
-    {.id = 142, .name = "One Way Interzone Adult 3 Zones"},
-    {.id = 143, .name = "One Way Interzone Adult 4 Zones"},
-    {.id = 144, .name = "One Way Interzone Adult 5 Zones"},
-    {.id = 145, .name = "One Way Interzone Adult 6 Zones"},
-    {.id = 146, .name = "One Way Interzone Adult 7 Zones"},
-    {.id = 147, .name = "One Way Interzone Adult 8 Zones"},
-
-    {.id = 428, .name = "One Way Half Fare Zone 1"},
-    {.id = 429, .name = "One Way Half Fare Zone 2"},
-    {.id = 430, .name = "One Way Half Fare Zone 3"},
-    {.id = 431, .name = "One Way Half Fare Zone 4"},
-    {.id = 432, .name = "One Way Half Fare Zone 5"},
-    {.id = 433, .name = "One Way Half Fare Zone 6"},
-    {.id = 434, .name = "One Way Half Fare Zone 7"},
-    {.id = 435, .name = "One Way Half Fare Zone 8"},
-    {.id = 436, .name = "One Way Interzone Half Fare 1 Zone"},
-    {.id = 437, .name = "One Way Interzone Half Fare 2 Zones"},
-    {.id = 438, .name = "One Way Interzone Half Fare 3 Zones"},
-    {.id = 439, .name = "One Way Interzone Half Fare 4 Zones"},
-    {.id = 440, .name = "One Way Interzone Half Fare 5 Zones"},
-    {.id = 441, .name = "One Way Interzone Half Fare 6 Zones"},
-    {.id = 442, .name = "One Way Interzone Half Fare 7 Zones"},
-    {.id = 443, .name = "One Way Interzone Half Fare 8 Zones"},
-
-    {.id = 509, .name = "Group Interzone 1 Zones"},
-    {.id = 510, .name = "Group Interzone 2 Zones"},
-    {.id = 511, .name = "Group Interzone 3 Zones"},
-    {.id = 512, .name = "Group Interzone 4 Zones"},
-    {.id = 513, .name = "Group Interzone 5 Zones"},
-    {.id = 514, .name = "Group Interzone 6 Zones"},
-    {.id = 515, .name = "Group Interzone 7 Zones"},
-    {.id = 516, .name = "Group Interzone 8 Zones"},
-
-    {.id = 952, .name = "Zone 1 Student Monthly Pass"},
-    {.id = 953, .name = "Zone 2 Student Monthly Pass"},
-    {.id = 954, .name = "Zone 3 Student Monthly Pass"},
-    {.id = 955, .name = "Zone 4 Student Monthly Pass"},
-    {.id = 956, .name = "Zone 5 Student Monthly Pass"},
-    {.id = 957, .name = "Zone 6 Student Monthly Pass"},
-    {.id = 958, .name = "Zone 7 Student Monthly Pass"},
-    {.id = 959, .name = "Zone 8 Student Monthly Pass"},
-    {.id = 960, .name = "Zone 9 Student Monthly Pass"},
-    {.id = 961, .name = "Zone 10 Student Monthly Pass"},
-
-    {.id = 963, .name = "Interzone 1 Zone Student Monthly Pass"},
-    {.id = 964, .name = "Interzone 2 Zone Student Monthly Pass"},
-    {.id = 965, .name = "Interzone 3 Zone Student Monthly Pass"},
-    {.id = 966, .name = "Interzone 4 Zone Student Monthly Pass"},
-    {.id = 967, .name = "Interzone 5 Zone Student Monthly Pass"},
-    {.id = 968, .name = "Interzone 6 Zone Student Monthly Pass"},
-    {.id = 969, .name = "Interzone 7 Zone Student Monthly Pass"},
-    {.id = 970, .name = "Interzone 8 Zone Student Monthly Pass"},
-    {.id = 971, .name = "Interzone 9 Zone Student Monthly Pass"},
-    {.id = 972, .name = "Interzone 10 Zone Student Monthly Pass"},
-};
-static const size_t kNumTypes = COUNT_OF(charliecard_types);
-
-// Incomplete, and subject to change
-// Only covers Orange & Blue line stations
-// Gathered manually, and provided courtesy of, DEFCON31 researchers
-// as cited above.
-static const IdMapping charliecard_fare_gate_ids[] = {
-    // Davis
-    {.id = 6766, .name = "Davis"},
-    {.id = 6767, .name = "Davis"},
-    {.id = 6768, .name = "Davis"},
-    {.id = 6769, .name = "Davis"},
-    {.id = 6770, .name = "Davis"},
-    {.id = 6771, .name = "Davis"},
-    {.id = 6772, .name = "Davis"},
-    {.id = 2167, .name = "Davis"},
-    {.id = 7020, .name = "Davis"},
-    // Porter
-    {.id = 6781, .name = "Porter"},
-    {.id = 6780, .name = "Porter"},
-    {.id = 6779, .name = "Porter"},
-    {.id = 6778, .name = "Porter"},
-    {.id = 6777, .name = "Porter"},
-    {.id = 6776, .name = "Porter"},
-    {.id = 6775, .name = "Porter"},
-    {.id = 2168, .name = "Porter"},
-    {.id = 7021, .name = "Porter"},
-    {.id = 6782, .name = "Porter"},
-    // Oak Grove
-    {.id = 6640, .name = "Oak Grove"},
-    {.id = 6641, .name = "Oak Grove"},
-    {.id = 6639, .name = "Oak Grove"},
-    {.id = 2036, .name = "Oak Grove"},
-    {.id = 6642, .name = "Oak Grove"},
-    {.id = 6979, .name = "Oak Grove"},
-    // Downtown Crossing
-    {.id = 2091, .name = "Downtown Crossing"},
-    {.id = 6995, .name = "Downtown Crossing"},
-    {.id = 6699, .name = "Downtown Crossing"},
-    {.id = 6700, .name = "Downtown Crossing"},
-    {.id = 1926, .name = "Downtown Crossing"},
-    {.id = 2084, .name = "Downtown Crossing"},
-    {.id = 6994, .name = "Downtown Crossing"},
-    {.id = 6695, .name = "Downtown Crossing"},
-    {.id = 6694, .name = "Downtown Crossing"},
-    {.id = 6696, .name = "Downtown Crossing"},
-    {.id = 2336, .name = "Downtown Crossing"},
-    {.id = 1056, .name = "Downtown Crossing"},
-    {.id = 6814, .name = "Downtown Crossing"},
-    {.id = 6813, .name = "Downtown Crossing"},
-    {.id = 2212, .name = "Downtown Crossing"},
-    {.id = 7038, .name = "Downtown Crossing"},
-    // State
-    {.id = 7092, .name = "State"},
-    {.id = 1844, .name = "State"},
-    {.id = 6689, .name = "State"},
-    {.id = 6988, .name = "State"},
-    {.id = 6991, .name = "State"},
-    {.id = 2083, .name = "State"},
-    {.id = 6688, .name = "State"},
-    {.id = 6687, .name = "State"},
-    {.id = 6686, .name = "State"},
-    {.id = 2078, .name = "State"},
-    {.id = 6987, .name = "State"},
-    {.id = 7090, .name = "State"},
-    {.id = 1842, .name = "State"},
-    // Haymarket
-    {.id = 6684, .name = "Haymarket"},
-    {.id = 6683, .name = "Haymarket"},
-    {.id = 6682, .name = "Haymarket"},
-    {.id = 6681, .name = "Haymarket"},
-    {.id = 2073, .name = "Haymarket"},
-    {.id = 7074, .name = "Haymarket"},
-    {.id = 6883, .name = "Haymarket"},
-    {.id = 6884, .name = "Haymarket"},
-    {.id = 6885, .name = "Haymarket"},
-    {.id = 6886, .name = "Haymarket"},
-    {.id = 2303, .name = "Haymarket"},
-    {.id = 6986, .name = "Haymarket"},
-    // North Station
-    {.id = 6985, .name = "North Station"},
-    {.id = 2063, .name = "North Station"},
-    {.id = 6671, .name = "North Station"},
-    {.id = 6672, .name = "North Station"},
-    {.id = 6673, .name = "North Station"},
-    {.id = 6674, .name = "North Station"},
-    {.id = 6675, .name = "North Station"},
-    {.id = 6676, .name = "North Station"},
-    {.id = 6677, .name = "North Station"},
-    {.id = 6678, .name = "North Station"},
-    {.id = 6984, .name = "North Station"},
-    {.id = 2062, .name = "North Station"},
-    {.id = 6668, .name = "North Station"},
-    {.id = 6667, .name = "North Station"},
-    {.id = 6666, .name = "North Station"},
-    {.id = 6665, .name = "North Station"},
-    {.id = 6664, .name = "North Station"},
-    // Sullivan Square
-    {.id = 6654, .name = "Sullivan Square"},
-    {.id = 6655, .name = "Sullivan Square"},
-    {.id = 6656, .name = "Sullivan Square"},
-    {.id = 6657, .name = "Sullivan Square"},
-    {.id = 6658, .name = "Sullivan Square"},
-    {.id = 6659, .name = "Sullivan Square"},
-    {.id = 2053, .name = "Sullivan Square"},
-    {.id = 6982, .name = "Sullivan Square"},
-    // Community College
-    {.id = 6661, .name = "Community College"},
-    {.id = 6662, .name = "Community College"},
-    {.id = 2056, .name = "Community College"},
-    {.id = 6983, .name = "Community College"},
-    // Assembly
-    {.id = 3876, .name = "Assembly"},
-    {.id = 3875, .name = "Assembly"},
-    {.id = 6957, .name = "Assembly"},
-    {.id = 6956, .name = "Assembly"},
-    {.id = 6955, .name = "Assembly"},
-    {.id = 6954, .name = "Assembly"},
-    {.id = 6953, .name = "Assembly"},
-    {.id = 7101, .name = "Assembly"},
-    {.id = 3873, .name = "Assembly"},
-    {.id = 3872, .name = "Assembly"},
-    // Wellington
-    {.id = 6981, .name = "Wellington"},
-    {.id = 2042, .name = "Wellington"},
-    {.id = 6650, .name = "Wellington"},
-    {.id = 6651, .name = "Wellington"},
-    {.id = 6652, .name = "Wellington"},
-    {.id = 6653, .name = "Wellington"},
-    // Malden
-    {.id = 6980, .name = "Malden Center"},
-    {.id = 2037, .name = "Malden Center"},
-    {.id = 6645, .name = "Malden Center"},
-    {.id = 6646, .name = "Malden Center"},
-    {.id = 6647, .name = "Malden Center"},
-    {.id = 6648, .name = "Malden Center"},
-    // Chinatown
-    {.id = 6704, .name = "Chinatown"},
-    {.id = 6705, .name = "Chinatown"},
-    {.id = 2099, .name = "Chinatown"},
-    {.id = 7003, .name = "Chinatown"},
-    {.id = 7002, .name = "Chinatown"},
-    {.id = 2096, .name = "Chinatown"},
-    {.id = 6702, .name = "Chinatown"},
-    {.id = 6701, .name = "Chinatown"},
-    // Tufts Medical Center
-    {.id = 6707, .name = "Tufts Medical Center"},
-    {.id = 6708, .name = "Tufts Medical Center"},
-    {.id = 6709, .name = "Tufts Medical Center"},
-    {.id = 6710, .name = "Tufts Medical Center"},
-    {.id = 6711, .name = "Tufts Medical Center"},
-    {.id = 2105, .name = "Tufts Medical Center"},
-    {.id = 7004, .name = "Tufts Medical Center"},
-    {.id = 1941, .name = "Tufts Medical Center"},
-    {.id = 7006, .name = "Tufts Medical Center"},
-    // Back Bay
-    {.id = 7007, .name = "Back Bay"},
-    {.id = 1480, .name = "Back Bay"},
-    {.id = 6714, .name = "Back Bay"},
-    {.id = 6715, .name = "Back Bay"},
-    {.id = 6716, .name = "Back Bay"},
-    {.id = 6717, .name = "Back Bay"},
-    {.id = 6718, .name = "Back Bay"},
-    {.id = 6719, .name = "Back Bay"},
-    {.id = 6720, .name = "Back Bay"},
-    {.id = 1801, .name = "Back Bay"},
-    {.id = 7009, .name = "Back Bay"},
-    // Massachusetts Avenue
-    {.id = 7010, .name = "Massachusetts Avenue"},
-    {.id = 2118, .name = "Massachusetts Avenue"},
-    {.id = 6724, .name = "Massachusetts Avenue"},
-    {.id = 6723, .name = "Massachusetts Avenue"},
-    {.id = 6722, .name = "Massachusetts Avenue"},
-    {.id = 6721, .name = "Massachusetts Avenue"},
-    // Ruggles
-    {.id = 6726, .name = "Ruggles"},
-    {.id = 6727, .name = "Ruggles"},
-    {.id = 6728, .name = "Ruggles"},
-    {.id = 2122, .name = "Ruggles"},
-    {.id = 2123, .name = "Ruggles"},
-    {.id = 2124, .name = "Ruggles"},
-    {.id = 1804, .name = "Ruggles"},
-    // Roxbury Crossing
-    {.id = 6737, .name = "Roxbury Crossing"},
-    {.id = 6736, .name = "Roxbury Crossing"},
-    {.id = 6735, .name = "Roxbury Crossing"},
-    {.id = 6734, .name = "Roxbury Crossing"},
-    {.id = 6733, .name = "Roxbury Crossing"},
-    {.id = 2125, .name = "Roxbury Crossing"},
-    {.id = 7012, .name = "Roxbury Crossing"},
-    // Jackson Square
-    {.id = 6741, .name = "Jackson Square"},
-    {.id = 6740, .name = "Jackson Square"},
-    {.id = 6739, .name = "Jackson Square"},
-    {.id = 2131, .name = "Jackson Square"},
-    {.id = 7013, .name = "Jackson Square"},
-    {.id = 7014, .name = "Jackson Square"},
-    {.id = 2135, .name = "Jackson Square"},
-    {.id = 6743, .name = "Jackson Square"},
-    {.id = 6744, .name = "Jackson Square"},
-    {.id = 6745, .name = "Jackson Square"},
-    // Green Street
-    {.id = 6746, .name = "Green Street"},
-    {.id = 6747, .name = "Green Street"},
-    {.id = 6748, .name = "Green Street"},
-    {.id = 2142, .name = "Green Street"},
-    {.id = 7015, .name = "Green Street"},
-    // Forest Hills
-    {.id = 6750, .name = "Forest Hills"},
-    {.id = 6751, .name = "Forest Hills"},
-    {.id = 6752, .name = "Forest Hills"},
-    {.id = 6753, .name = "Forest Hills"},
-    {.id = 6754, .name = "Forest Hills"},
-    {.id = 6755, .name = "Forest Hills"},
-    {.id = 2150, .name = "Forest Hills"},
-    {.id = 7016, .name = "Forest Hills"},
-    {.id = 6950, .name = "Forest Hills"},
-    {.id = 6951, .name = "Forest Hills"},
-    {.id = 604, .name = "Forest Hills"},
-    {.id = 7096, .name = "Forest Hills"},
-    // South Station
-    {.id = 7039, .name = "South Station"},
-    {.id = 2215, .name = "South Station"},
-    {.id = 6816, .name = "South Station"},
-    {.id = 6817, .name = "South Station"},
-    {.id = 6818, .name = "South Station"},
-    {.id = 6819, .name = "South Station"},
-    {.id = 6820, .name = "South Station"},
-    {.id = 6821, .name = "South Station"},
-    {.id = 6822, .name = "South Station"},
-    {.id = 6823, .name = "South Station"},
-    {.id = 7040, .name = "South Station"},
-    {.id = 2228, .name = "South Station"},
-    {.id = 6827, .name = "South Station"},
-    {.id = 6826, .name = "South Station"},
-    {.id = 6825, .name = "South Station"},
-    {.id = 6824, .name = "South Station"},
-    // Courthouse
-    {.id = 6929, .name = "Courthouse"},
-    {.id = 2357, .name = "Courthouse"},
-    {.id = 7079, .name = "Courthouse"},
-    {.id = 6933, .name = "Courthouse"},
-    {.id = 6932, .name = "Courthouse"},
-    {.id = 2358, .name = "Courthouse"},
-    {.id = 6792, .name = "Courthouse"},
-    // Bowdoin
-    {.id = 6937, .name = "Bowdoin"},
-    {.id = 2367, .name = "Bowdoin"},
-    {.id = 7085, .name = "Bowdoin"},
-    // Government Center
-    {.id = 6963, .name = "Government Center"},
-    {.id = 6962, .name = "Government Center"},
-    {.id = 6961, .name = "Government Center"},
-    {.id = 6960, .name = "Government Center"},
-    {.id = 6959, .name = "Government Center"},
-    {.id = 6958, .name = "Government Center"},
-    {.id = 5298, .name = "Government Center"},
-    // Aquarium
-    {.id = 6609, .name = "Aquarium"},
-    {.id = 6608, .name = "Aquarium"},
-    {.id = 1877, .name = "Aquarium"},
-    {.id = 6965, .name = "Aquarium"},
-    {.id = 6610, .name = "Aquarium"},
-    {.id = 1880, .name = "Aquarium"},
-    {.id = 1871, .name = "Aquarium"},
-    {.id = 6966, .name = "Aquarium"},
-    // Maverick
-    {.id = 7088, .name = "Maverick"},
-    {.id = 6944, .name = "Maverick"},
-    {.id = 4384, .name = "Maverick"},
-    {.id = 6946, .name = "Maverick"},
-    {.id = 6947, .name = "Maverick"},
-    {.id = 6948, .name = "Maverick"},
-    {.id = 6949, .name = "Maverick"},
-    {.id = 1840, .name = "Maverick"},
-    {.id = 7083, .name = "Maverick"},
-    // Airport
-    {.id = 6613, .name = "Airport"},
-    {.id = 6612, .name = "Airport"},
-    {.id = 6611, .name = "Airport"},
-    {.id = 6968, .name = "Airport"},
-    {.id = 2009, .name = "Airport"},
-    {.id = 6616, .name = "Airport"},
-    {.id = 6615, .name = "Airport"},
-    {.id = 6614, .name = "Airport"},
-    {.id = 6970, .name = "Airport"},
-    {.id = 1847, .name = "Airport"},
-    // Wood Island
-    {.id = 6618, .name = "Wood Island"},
-    {.id = 6619, .name = "Wood Island"},
-    {.id = 2010, .name = "Wood Island"},
-    {.id = 6971, .name = "Wood Island"},
-    // Orient Heights
-    {.id = 6621, .name = "Orient Heights"},
-    {.id = 6622, .name = "Orient Heights"},
-    {.id = 6623, .name = "Orient Heights"},
-    {.id = 2014, .name = "Orient Heights"},
-    {.id = 6972, .name = "Orient Heights"},
-    {.id = 6974, .name = "Orient Heights"},
-    {.id = 1868, .name = "Orient Heights"},
-    // Suffolk Downs
-    {.id = 6625, .name = "Suffolk Downs"},
-    {.id = 6626, .name = "Suffolk Downs"},
-    {.id = 2017, .name = "Suffolk Downs"},
-    {.id = 6975, .name = "Suffolk Downs"},
-    // Beachmont
-    {.id = 6628, .name = "Beachmont"},
-    {.id = 6629, .name = "Beachmont"},
-    {.id = 6630, .name = "Beachmont"},
-    {.id = 2021, .name = "Beachmont"},
-    {.id = 6976, .name = "Beachmont"},
-    // Revere Beach
-    {.id = 6632, .name = "Revere Beach"},
-    {.id = 6633, .name = "Revere Beach"},
-    {.id = 2024, .name = "Revere Beach"},
-    {.id = 6977, .name = "Revere Beach"},
-    // Wonderland
-    {.id = 6638, .name = "Wonderland"},
-    {.id = 6637, .name = "Wonderland"},
-    {.id = 6636, .name = "Wonderland"},
-    {.id = 2025, .name = "Wonderland"},
-    {.id = 6978, .name = "Wonderland"},
-};
-static const size_t kNumFareGateIds = COUNT_OF(charliecard_fare_gate_ids);
-
-// **********************************************************
-// ********************* MISC HELPERS ***********************
-// **********************************************************
-
-static const uint8_t*
-    pos_to_ptr(const MfClassicData* data, uint8_t sector_num, uint8_t block_num, uint8_t byte_num) {
-    // returns pointer to specified sector/block/byte of MFClassic card data
-    uint8_t block_offset = mf_classic_get_first_block_num_of_sector(sector_num);
-    return &data->block[block_offset + block_num].data[byte_num];
-}
-
-static uint64_t pos_to_num(
-    const MfClassicData* data,
-    uint8_t sector_num,
-    uint8_t block_num,
-    uint8_t byte_num,
-    uint8_t byte_len) {
-    // returns numeric values at specified card location, for given byte length.
-    // assumes big endian.
-    return bit_lib_bytes_to_num_be(pos_to_ptr(data, sector_num, block_num, byte_num), byte_len);
-}
-
-static DateTime dt_delta(DateTime dt, uint64_t delta_secs) {
-    // returns shifted DateTime, from initial DateTime and time offset in seconds
-    DateTime dt_shifted = {0};
-    datetime_timestamp_to_datetime(datetime_datetime_to_timestamp(&dt) + delta_secs, &dt_shifted);
-
-    return dt_shifted;
-}
-
-static bool dt_ge(DateTime dt1, DateTime dt2) {
-    // compares two DateTimes
-    return datetime_datetime_to_timestamp(&dt1) >= datetime_datetime_to_timestamp(&dt2);
-}
-
-static bool dt_eq(DateTime dt1, DateTime dt2) {
-    // compares two DateTimes
-    return datetime_datetime_to_timestamp(&dt1) == datetime_datetime_to_timestamp(&dt2);
-}
-
-static bool get_map_item(uint16_t id, const IdMapping* map, size_t sz, const char** out) {
-    // code borrowed from Jeremy Cooper's 'clipper.c'. Used as follows:
-    // const char* s; if(!get_map_item(_,_,_,&s)) {s="Default str";}
-    // TODO: change to furistring out?
-    for(size_t i = 0; i < sz; i++) {
-        if(map[i].id == id) {
-            *out = map[i].name;
-            return true;
-        }
-    }
-
-    return false;
-}
-
-uint32_t time_now() {
-    return furi_hal_rtc_get_timestamp();
-}
-
-static bool is_debug() {
-    return furi_hal_rtc_is_flag_set(FuriHalRtcFlagDebug);
-}
-
-// **********************************************************
-// ******************** FIELD PARSING ***********************
-// **********************************************************
-
-static Money money_parse(
-    const MfClassicData* data,
-    uint8_t sector_num,
-    uint8_t block_num,
-    uint8_t byte_num) {
-    // CharlieCards store all money values in two bytes as half-cents
-    // bitmask removes sign/flag, bitshift converts half-cents to cents, div & mod yield dollars & cents
-    uint16_t amt = (pos_to_num(data, sector_num, block_num, byte_num, 2) & 0x7FFF) >> 1;
-    return (Money){amt / 100, amt % 100};
-}
-
-static DateTime
-    date_parse(const MfClassicData* data, uint8_t sector_num, uint8_t block_num, uint8_t byte_num) {
-    // Dates are 3 bytes, in minutes since 2003/1/1 ("CHARLIE_EPOCH")
-    uint32_t ts_charlie = pos_to_num(data, sector_num, block_num, byte_num, 3);
-    return dt_delta(CHARLIE_EPOCH, ts_charlie * CHARLIE_TIME_DELTA_SECS);
-}
-
-static DateTime end_validity_parse(
-    const MfClassicData* data,
-    uint8_t sector_num,
-    uint8_t block_num,
-    uint8_t byte_num) {
-    // End validity field is weird; shares first byte with another variable (the card type field),
-    // occupying the last 5 bits (and subsequent two bytes), hence bitmask
-    uint32_t ts_charlie_ev = pos_to_num(data, sector_num, block_num, byte_num, 3) & 0x1FFFFF;
-
-    // additionally, instead of minute deltas, is in 8 minute increments
-    // relative to CHARLIE_EPOCH (2003/1/1), per DEFCON31 researcher's work
-    return dt_delta(CHARLIE_EPOCH, ts_charlie_ev * CHARLIE_END_VALID_DELTA_SECS);
-}
-
-static Pass
-    pass_parse(const MfClassicData* data, uint8_t sector_num, uint8_t block_num, uint8_t byte_num) {
-    // WIP; testing only. Speculating it may be structured as follows
-    // Sub-byte field divisions not drawn to scale, see code for exact bit offsets
-    //
-    //       0    1    2    3    4    5
-    //       +----.----.----.----+----.----+
-    //       |  uk1  |   date    |   uk2   |
-    //       +----.----.----.----+----.----+
-    //
-    // "Blank" entries are as follows:
-    //       0    1    2    3    4    5
-    //       +----.----.----.----.----.----+
-    //       | 00   20   00   00   00   00 |
-    //       +----.----.----.----.----.----+
-    //
-    // even when not blank, uk1 LSB seems to always be set to 1...
-    // the sole bit set to 1 on the blank entry seems to divide
-    // the uk1 and date fields, and is always set to 1 regardless
-    // same is true of type & end-validity split found in balance sector
-    //
-    // likely fields incl
-    // — type #,
-    // — a secondary date field (eg start/end, end validity or normal format)
-    // — ID of FVM from which the pass was loaded
-
-    // check for empty, if so, return struct filled w/ 0s
-    // (incl "valid" field: hence, "valid" is false-y)
-    if(pos_to_num(data, sector_num, block_num, byte_num, 6) == 0x002000000000) {
-        return (Pass){0};
-    }
-
-    // const DateTime start = date_parse(data, sector_num, block_num, byte_num + 1);
-
-    const uint16_t pre = pos_to_num(data, sector_num, block_num, byte_num, 2) >> 6;
-    const uint16_t post = (pos_to_num(data, sector_num, block_num, byte_num + 4, 2) >> 2) & 0x3ff;
-
-    // these values make sense for a date, but implied position of type
-    // before end validity, as seen in balance sector, doesn't seem
-    // to produce sensible values
-    const DateTime date = end_validity_parse(data, sector_num, block_num, byte_num + 1);
-
-    // DateTime start = date_parse(data, sector_num, block_num, byte_num);
-    // uint16_t type = 0; // pos_to_num(data, sector_num, block_num, byte_num + 3, 2) >> 6;
-
-    return (Pass){true, pre, post, date};
-}
-
-static Transaction
-    transaction_parse(const MfClassicData* data, uint8_t sector, uint8_t block, uint8_t byte) {
-    // This function parses individual transactions. Each transaction packs 7 bytes, stored as follows:
-    //
-    //       0    1    2    3    4    5    6
-    //       +----.----.----+----.--+-+----.----+
-    //       |     date     |   loc |f|   amt   |
-    //       +----.----.----+----.--+-+----.----+
-    //
-    // Where date is in the typical format, loc represents the fare gate tapped, and amt is the fare amount.
-    // Amount appears to contain some flag bits, however, it is unclear what precisely their function is.
-    //
-    // Gate ID ("loc") is only the first 13 bits of 0x3:0x5, the final three bits appear to be flags ("f").
-    // Least significant flag bit seems to indicate:
-    // — When f & 1 == 1, fare (the amount by which balance is decremented)
-    // — When f & 1 == 0, refill (the amount by which balance is incremented)
-    // MSB (sign bit) of amt seems to serve the same role, just inverted, ie
-    // — When amt & 0x8000 == 0, fare
-    // — When amt & 0x8000 == 0x8000, refill
-    // Only contradiction between the two observed is on cards w/ passes;
-    // MSB of amt seems to be set for every transaction when (remaining bits of) amt is 0 on a card w/ a pass
-    // Hence, using f's LSB as method for inferring fare v. refill
-    //
-    // Remaining unknown bits:
-    // — f & 0b100; seems to be set on fares where the card has a pass, and amt is 0
-    // — f & 0b010
-    // — amt & 1; does not seem to correspond with card type, last transaction, first transaction, refill v. fare, etc
-
-    const DateTime date = date_parse(data, sector, block, byte);
-    const uint16_t gate = pos_to_num(data, sector, block, byte + 3, 2) >> 3;
-    const uint8_t g_flag = pos_to_num(data, sector, block, byte + 3, 2) & 0b111;
-    const Money fare = money_parse(data, sector, block, byte + 5);
-    const uint16_t f_flag = pos_to_num(data, sector, block, byte + 5, 2) & 0x8001;
-    return (Transaction){date, gate, g_flag, fare, f_flag};
-}
-
-// **********************************************************
-// ******************* SECTOR PARSING ***********************
-// **********************************************************
-
-static uint32_t mfg_sector_parse(const MfClassicData* data) {
-    // Manufacturer data (Sector 0)
-    //
-    //       0    1    2    3    4    5    6    7    8    9    A    B    C    D    E    F
-    //       +----.----.----.----+----+----.----.----.----+----+----.----.----.----.----+----+
-    // 0x000 |        UID        | rc | 88   04   00   C8 | uk | 00   20   00   00   00 | uk |
-    //       +----.----.----.----+----+----.----.----.----+----+----.----.----.----.----+----+
-    // 0x010 | 4E   0F   04   10   04   10   04   10   04   10   04   10   04   10   04   10 |
-    //       +----.----.----.----.----.----.----.----.----.----.----.----.----.----.----.----+
-    // 0x020 |                               ...  00   00  ...                               |
-    //       +----.----.----.----.----.----.----.----.----.----.----.----.----.----.----.----+
-    //
-    // rc := "redundancy check" (lrc / bcc)
-    // uk := "unknown"
-
-    size_t uid_len = 0;
-    const uint8_t* uid = mf_classic_get_uid(data, &uid_len);
-    const uint32_t card_number = bit_lib_bytes_to_num_be(uid, 4);
-
-    return card_number;
-}
-
-static CounterSector counter_sector_parse(const MfClassicData* data) {
-    // Trip/transaction counters (Sector 1)
-    //
-    //       0    1    2    3    4    5    6    7    8    9    A    B    C    D    E    F
-    //       +----.----.----.----.----.----.----.----.----.----.----.----.----.----.----.----+
-    // 0x040 | 04   10   23   45   66   77                  ...  00   00  ...                |
-    //       +----.----+----+----.----.----.----.----.----.----.----.----.----.----.----.----+
-    // 0x050 |  uses1  | uk |                     ...  00   00  ...                          |
-    //       +----.----+----+----.----.----.----.----.----.----.----.----.----.----.----.----+
-    // 0x060 |  uses2  | uk |                     ...  00   00  ...                          |
-    //       +----.----+----+----.----.----.----.----.----.----.----.----.----.----.----.----+
-    //
-    // uk := "unknown"; if nonzero, seems to only occupy the first 4 bits (ie, uk & 0xF0 == uk),
-    //        with the remaining 4 zero
-
-    // Card has two sectors (2 & 3) containing balance data, with two
-    // corresponding trip counters in 0x50:0x51 & 0x60:0x61 (sector 1, byte 0:1 of blocks 1 & 2).
-
-    // The *lower* of the two values *minus one* is the true use count,
-    // and corresponds to the active balance sector,
-    // (0x50 counter lower -> sector 2 active, 0x60 counter lower -> 3 active)
-    // per DEFCON31 researcher's findings
-
-    const uint16_t n_uses1 = pos_to_num(data, 1, 1, 0, 2);
-    const uint16_t n_uses2 = pos_to_num(data, 1, 2, 0, 2);
-
-    const bool is_sec2_active = n_uses1 <= n_uses2;
-    const uint8_t active_sector = is_sec2_active ? 2 : 3;
-    const uint16_t n_uses = (is_sec2_active ? n_uses1 : n_uses2) - 1;
-
-    return (CounterSector){n_uses, active_sector};
-}
-
-static BalanceSector balance_sector_parse(const MfClassicData* data, uint8_t active_sector) {
-    // Balance & misc card info (Sector 2 or 3)
-    //
-    //       0    1    2    3    4    5    6    7    8    9    A    B    C    D    E    F
-    //       +----+----.----.----+----.----+----.----.----+----.----+----.----+----+----.----+
-    // 0x080 | 11 |   date last  | loc last| date issued  | 65   00 | unknown | 00 |   crc   | 0x0C0
-    //       +----+----.----.----+----+----+----+----+----+----.----+----.----+----+----.----+
-    // 0x090 | type |end validity| uk | balance | 00 |           unknown           |   crc   | 0x0D0
-    //       +----.----.----.----+----+----.----+----+----.----.----.----.----.----+----.----+
-    // 0x0A0 |      20             ...  00   00  ...             04                |   crc   | 0x0E0
-    //       +----.----.----.----.----.----.----.----.----.----.----.----.----.----+----.----+
-    //
-    // "Active" balance sector alternates between 2 and 3
-    // Last trip/transaction info in balance sector ("date last" & "loc last")
-    // is also included in transaction log, hence don't bother to read here
-    //
-    // Inactive balance sector represent the transaction N-1 version
-    // (where active sector represents data from transaction N).
-
-    const DateTime issued = date_parse(data, active_sector, 0, 6);
-    const DateTime end_validity = end_validity_parse(data, active_sector, 1, 1);
-    // Card type data stored in the first 10bits of block 1
-    // (0x90 or 0xD0 depending on active sector)
-    // bitshift (2bytes = 16 bits) by 6bits for just first 10bits
-    const uint16_t type = pos_to_num(data, active_sector, 1, 0, 2) >> 6;
-    const Money bal = money_parse(data, active_sector, 1, 5);
-
-    return (BalanceSector){bal, type, issued, end_validity};
-}
-
-static Pass* passes_parse(const MfClassicData* data) {
-    // Passes, speculative (Sectors 4 &/or 5)
-    //
-    //       0    1    2    3    4    5    6    7    8    9    A    B    C    D    E    F
-    //       +----.----.----.----.----.----+----+----.----.----.----.----.----+----+----.----+
-    // 0x100 |           pass0/2?          | 00 |           pass1/3?          | 00 |   crc   | 0x140
-    //       +----.----.----.----.----.----+----+----.----.----.----.----.----+----+----.----+
-    // 0x110 |                          ...  00   00  ...                          |   crc   | 0x150
-    //       +----.----.----.----.----.----.----.----.----.----.----.----.----.----+----.----+
-    // 0x120 |                ...  00  ...                  05                     |   crc   | 0x160
-    //       +----.----.----.----.----.----.----.----.----.----.----.----.----.----+----.----+
-    //
-    // WIP. Read in all speculative passes into array
-    // 4 separate fields? active vs inactive sector for 2 passes?
-    // something else entirely?
-
-    Pass* passes = malloc(sizeof(Pass) * CHARLIE_N_PASSES);
-
-    for(size_t i = 0; i < CHARLIE_N_PASSES; i++) {
-        passes[i] = pass_parse(data, 4 + (i / 2), 0, (i % 2) * 7);
-    }
-
-    return passes;
-}
-
-static Transaction* transactions_parse(const MfClassicData* data) {
-    // Transaction history (Sectors 6–7)
-    //
-    //       0    1    2    3    4    5    6    7    8    9    A    B    C    D    E    F
-    //       +----.----.----.----.----.----.----+----.----.----.----.----.----.----+----.----+
-    // 0x180 |           transaction0           |           transaction1           |   crc   |
-    //       +----.----.----.----.----.----.----+----.----.----.----.----.----.----+----.----+
-    //  ...                   ...                                ...                   ...
-    //       +----.----.----.----.----.----.----+----.----.----.----.----.----.----+----.----+
-    // 0x1D0 |           transaction8           |           transaction9           |   crc   |
-    //       +----.----.----.----.----.----.----+----.----.----.----.----.----.----+----.----+
-    // 0x1E0 |                          ...  00   00  ...                          |   crc   |
-    //       +----.----.----.----.----.----.----.----.----.----.----.----.----.----+----.----+
-    //
-    // Transactions are not sorted, rather, appear to get overwritten
-    // sequentially. (eg, sorted modulo array rotation)
-
-    Transaction* transactions = malloc(sizeof(Transaction) * CHARLIE_N_TRANSACTION_HISTORY);
-
-    // Parse each transaction field using some modular math magic to get the offsets:
-    // move from sector 6 -> 7 after the first 6 transactions
-    // move a block within a given sector every 2 transactions, reset every 3 blocks (as sector has changed)
-    // alternate between a start byte of 0 and 7 with every iteration
-    for(size_t i = 0; i < CHARLIE_N_TRANSACTION_HISTORY; i++) {
-        transactions[i] = transaction_parse(data, 6 + (i / 6), (i / 2) % 3, (i % 2) * 7);
-    }
-
-    // Iterate through the array to find the maximum (newest) date value
-    int max_idx = 0;
-    for(int i = 1; i < CHARLIE_N_TRANSACTION_HISTORY; i++) {
-        if(dt_ge(transactions[i].date, transactions[max_idx].date)) {
-            max_idx = i;
-        }
-    }
-
-    // Sort by rotating
-    for(int r = 0; r < (max_idx + 1); r++) {
-        // Store the first element
-        Transaction temp = transactions[0];
-        // Shift elements to the left
-        for(int i = 0; i < CHARLIE_N_TRANSACTION_HISTORY - 1; i++) {
-            transactions[i] = transactions[i + 1];
-        }
-        // Move the first element to the last
-        transactions[CHARLIE_N_TRANSACTION_HISTORY - 1] = temp;
-    }
-
-    // Reverse order, such that newest is first, oldest last
-    for(int i = 0; i < CHARLIE_N_TRANSACTION_HISTORY / 2; i++) {
-        // Swap elements at index i and size - i - 1
-        Transaction temp = transactions[i];
-        transactions[i] = transactions[CHARLIE_N_TRANSACTION_HISTORY - i - 1];
-        transactions[CHARLIE_N_TRANSACTION_HISTORY - i - 1] = temp;
-    }
-
-    return transactions;
-}
-
-/*
-static DateTime expiry(DateTime iss) {
-    // Per Metrodroid CharlieCard parser (https://github.com/metrodroid/metrodroid/blob/master/src/commonMain/kotlin/au/id/micolous/metrodroid/transit/charlie/CharlieCardTransitData.kt)
-    // Expiry not explicitly stored in card data; rather, calculated from date of issue
-    // Cards were first issued in 2006, expired in 5 years, w/ no printed expiry date
-    // Cards issued after 2011 expire in 10 years
-    //
-    // Per DEFCON31 researcher's work (cited above):
-    // Student cards last one school year and expire at the end of August the following year
-    // Pre-2011 issued cards expire in 7 years, not 5 as claimed by Metrodroid
-    // Post-2011 expire in 10 years, less one day
-    // Redundant function given the existance of the end validity field?
-    // Any important distinctions between the two?
-    
-
-    // perhaps additionally clipping to 2030-12-__ in anticipation of upcoming system migration?
-    // need to get a new card to confirm.
-
-    // TODO add card type logic for student card expiry
-    DateTime exp;
-    if(iss.year < 2011) {
-        // add 7 years; assumes average year of 8766 hrs (to account for leap years)
-        // may be off by a few hours as a result
-        exp = dt_delta(iss, 7 * 8766 * 60 * 60);
-    } else {
-        // add 10 years, subtract a day. Same assumption as above
-        exp = dt_delta(iss, ((10 * 8766) - 24) * 60 * 60);
-    }
-
-    return exp;
-}
-
-static bool expired(DateTime expiry, DateTime last_transaction) {
-    // if a card has sat unused for >2 years, expired (verify this claim?)
-    // else expired if current date > expiry date
-
-    uint32_t ts_exp = datetime_datetime_to_timestamp(&expiry);
-    uint32_t ts_last = datetime_datetime_to_timestamp(&last_transaction);
-    uint32_t ts_now = time_now();
-
-    return (ts_exp <= ts_now) | ((ts_now - ts_last) >= (2 * 365 * 24 * 60 * 60));
-}
-*/
-
-// **********************************************************
-// ****************** STRING FORMATTING *********************
-// **********************************************************
-
-void locale_format_dt_cat(FuriString* out, const DateTime* dt) {
-    // helper to print datetimes
-    FuriString* s = furi_string_alloc();
-
-    LocaleDateFormat date_format = locale_get_date_format();
-    const char* separator = (date_format == LocaleDateFormatDMY) ? "." : "/";
-    locale_format_date(s, dt, date_format, separator);
-    furi_string_cat(out, s);
-    locale_format_time(s, dt, locale_get_time_format(), false);
-    furi_string_cat_printf(out, "  ");
-    furi_string_cat(out, s);
-
-    furi_string_free(s);
-}
-
-void type_format_cat(FuriString* out, uint16_t type) {
-    const char* s;
-    if(!get_map_item(type, charliecard_types, kNumTypes, &s)) {
-        s = "";
-        furi_string_cat_printf(out, "Unknown-%u", type);
-    }
-
-    furi_string_cat_str(out, s);
-}
-
-void pass_format_cat(FuriString* out, Pass pass) {
-    furi_string_cat_printf(out, "\n-Pre: %b", pass.pre);
-    // type_format_cat(out, pass.type);
-    furi_string_cat_printf(out, "\n-Post: ");
-    type_format_cat(out, pass.post);
-    // locale_format_dt_cat(out, &pass.start);
-    furi_string_cat_printf(out, "\n-Date: ");
-    locale_format_dt_cat(out, &pass.date);
-}
-
-void passes_format_cat(FuriString* out, Pass* passes) {
-    // only print passes if DEBUG on
-    if(!is_debug()) {
-        return;
-    }
-
-    // only print if there is at least 1 valid pass to print
-    bool any_valid = false;
-    for(size_t i = 0; i < CHARLIE_N_PASSES; i++) {
-        any_valid |= passes[i].valid;
-    }
-    if(!any_valid) {
-        return;
-    }
-
-    furi_string_cat_printf(out, "\nPasses (DEBUG / WIP):");
-    for(size_t i = 0; i < CHARLIE_N_PASSES; i++) {
-        if(passes[i].valid) {
-            furi_string_cat_printf(out, "\nPass %u", i + 1);
-            pass_format_cat(out, passes[i]);
-            furi_string_cat_printf(out, "\n");
-        }
-    }
-}
-
-void money_format_cat(FuriString* out, Money money) {
-    furi_string_cat_printf(out, "$%u.%02u", money.dollars, money.cents);
-}
-
-void transaction_format_cat(FuriString* out, Transaction transaction) {
-    const char* sep = "   ";
-    const char* sta;
-
-    locale_format_dt_cat(out, &transaction.date);
-    furi_string_cat_printf(out, "\n%s", !!(transaction.g_flag & 0x1) ? "-" : "+");
-    money_format_cat(out, transaction.fare);
-    if(!!(transaction.g_flag & 0x1) && (transaction.fare.dollars == FARE_BUS.dollars) &&
-       (transaction.fare.cents == FARE_BUS.cents)) {
-        // if not a refill, and the fare amount is equal to bus fare (any better approach? flag bits for modality?)
-        // format for bus — supposedly some correlation between gate ID & bus #, haven't investigated
-        furi_string_cat_printf(out, "%s#%u", sep, transaction.gate);
-    } else if(get_map_item(transaction.gate, charliecard_fare_gate_ids, kNumFareGateIds, &sta)) {
-        // station found in fare gate ID map, append station name
-        furi_string_cat_str(out, sep);
-        furi_string_cat_str(out, sta);
-    } else {
-        // no found station in fare gate ID map & not a bus, just print ID w/o add'l info
-        furi_string_cat_printf(out, "%s#%u", sep, transaction.gate);
-    }
-    // print flags for debugging purposes
-    if(is_debug()) {
-        furi_string_cat_printf(out, "%s%x%s%x", sep, transaction.g_flag, sep, transaction.f_flag);
-    }
-}
-
-void transactions_format_cat(FuriString* out, Transaction* transactions) {
-    furi_string_cat_printf(out, "\nTransactions:");
-    for(size_t i = 0; i < CHARLIE_N_TRANSACTION_HISTORY; i++) {
-        furi_string_cat_printf(out, "\n");
-        transaction_format_cat(out, transactions[i]);
-        furi_string_cat_printf(out, "\n");
-    }
-}
-
-// **********************************************************
-// **************** NFC PLUGIN BOILERPLATE ******************
-// **********************************************************
-
-static bool charliecard_parse(FuriString* parsed_data, const MfClassicData* data) {
-    bool parsed = false;
-
-    do {
-        // Verify key
-        // arbitrary sector in the main data portion
-        const uint8_t verify_sector = 3;
-        const MfClassicSectorTrailer* sec_tr =
-            mf_classic_get_sector_trailer_by_sector(data, verify_sector);
-
-        const uint64_t key_a =
-            bit_lib_bytes_to_num_be(sec_tr->key_a.data, COUNT_OF(sec_tr->key_a.data));
-
-        if(key_a != charliecard_1k_keys[verify_sector].a) break;
-
-        // parse card data
-        const uint32_t card_number = mfg_sector_parse(data);
-        const CounterSector counter_sector = counter_sector_parse(data);
-        const BalanceSector balance_sector =
-            balance_sector_parse(data, counter_sector.active_balance_sector);
-        Pass* passes = passes_parse(data);
-        Transaction* transactions = transactions_parse(data);
-
-        // print/append card data
-        furi_string_cat_printf(parsed_data, "\e#CharlieCard");
-        furi_string_cat_printf(parsed_data, "\nSerial: 5-%lu", card_number);
-
-        // Type and balance 0 on some (Perq) cards
-        // (ie no "main" type / balance / end validity,
-        //  essentially only pass & trip info)
-        // skip/change formatting for that case?
-        furi_string_cat_printf(parsed_data, "\nBal: ");
-        money_format_cat(parsed_data, balance_sector.balance);
-
-        furi_string_cat_printf(parsed_data, "\nType: ");
-        type_format_cat(parsed_data, balance_sector.type);
-
-        furi_string_cat_printf(parsed_data, "\nTrip Count: %u", counter_sector.n_uses);
-
-        furi_string_cat_printf(parsed_data, "\nIssued: ");
-        locale_format_dt_cat(parsed_data, &balance_sector.issued);
-
-        if(!dt_eq(balance_sector.end_validity, CHARLIE_EPOCH) &
-           dt_ge(balance_sector.end_validity, balance_sector.issued)) {
-            // sometimes (seen on Perq cards) end validity field is all 0
-            // When this is the case, calc'd end validity is equal to CHARLIE_EPOCH).
-            // Only print if not 0, & end validity after issuance date
-            furi_string_cat_printf(parsed_data, "\nExpiry: ");
-            locale_format_dt_cat(parsed_data, &balance_sector.end_validity);
-        }
-
-        // const DateTime last = date_parse(data, active_sector, 0, 1);
-        // furi_string_cat_printf(parsed_data, "\nExpired: %s", expired(e_v, last) ? "Yes" : "No");
-
-        transactions_format_cat(parsed_data, transactions);
-        free(transactions);
-
-        passes_format_cat(parsed_data, passes);
-        free(passes);
-
-        parsed = true;
-    } while(false);
-
-    return parsed;
-}
-
-static NfcCommand
-    metroflip_scene_charlicard_poller_callback(NfcGenericEvent event, void* context) {
-    furi_assert(context);
-    furi_assert(event.event_data);
-    furi_assert(event.protocol == NfcProtocolMfClassic);
-
-    NfcCommand command = NfcCommandContinue;
-    const MfClassicPollerEvent* mfc_event = event.event_data;
-    Metroflip* app = context;
-
-    if(mfc_event->type == MfClassicPollerEventTypeCardDetected) {
-        view_dispatcher_send_custom_event(app->view_dispatcher, MetroflipCustomEventCardDetected);
-        command = NfcCommandContinue;
-    } else if(mfc_event->type == MfClassicPollerEventTypeCardLost) {
-        view_dispatcher_send_custom_event(app->view_dispatcher, MetroflipCustomEventCardLost);
-        app->sec_num = 0;
-        command = NfcCommandStop;
-    } else if(mfc_event->type == MfClassicPollerEventTypeRequestMode) {
-        mfc_event->data->poller_mode.mode = MfClassicPollerModeRead;
-
-    } else if(mfc_event->type == MfClassicPollerEventTypeRequestReadSector) {
-        MfClassicKey key = {0};
-        bit_lib_num_to_bytes_be(charliecard_1k_keys[app->sec_num].a, COUNT_OF(key.data), key.data);
-
-        MfClassicKeyType key_type = MfClassicKeyTypeA;
-        mfc_event->data->read_sector_request_data.sector_num = app->sec_num;
-        mfc_event->data->read_sector_request_data.key = key;
-        mfc_event->data->read_sector_request_data.key_type = key_type;
-        mfc_event->data->read_sector_request_data.key_provided = true;
-        if(app->sec_num == 16) {
-            mfc_event->data->read_sector_request_data.key_provided = false;
-            app->sec_num = 0;
-        }
-        app->sec_num++;
-    } else if(mfc_event->type == MfClassicPollerEventTypeSuccess) {
-        nfc_device_set_data(
-            app->nfc_device, NfcProtocolMfClassic, nfc_poller_get_data(app->poller));
-        const MfClassicData* mfc_data = nfc_device_get_data(app->nfc_device, NfcProtocolMfClassic);
-        FuriString* parsed_data = furi_string_alloc();
-        Widget* widget = app->widget;
-
-        dolphin_deed(DolphinDeedNfcReadSuccess);
-        furi_string_reset(app->text_box_store);
-        if(!charliecard_parse(parsed_data, mfc_data)) {
-            furi_string_reset(app->text_box_store);
-            FURI_LOG_I(TAG, "Unknown card type");
-            furi_string_printf(parsed_data, "\e#Unknown card\n");
-        }
-        widget_add_text_scroll_element(widget, 0, 0, 128, 64, furi_string_get_cstr(parsed_data));
-
-        widget_add_button_element(
-            widget, GuiButtonTypeRight, "Exit", metroflip_exit_widget_callback, app);
-
-        furi_string_free(parsed_data);
-        view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewWidget);
-        command = NfcCommandStop;
-        metroflip_app_blink_stop(app);
-    } else if(mfc_event->type == MfClassicPollerEventTypeFail) {
-        FURI_LOG_I(TAG, "fail");
-        command = NfcCommandContinue;
-    }
-
-    return command;
-}
-
-void metroflip_scene_charliecard_on_enter(void* context) {
-    Metroflip* app = context;
-    dolphin_deed(DolphinDeedNfcRead);
-
-    app->sec_num = 0;
-
-    // Setup view
-    Popup* popup = app->popup;
-    popup_set_header(popup, "Apply\n card to\nthe back", 68, 30, AlignLeft, AlignTop);
-    popup_set_icon(popup, 0, 3, &I_RFIDDolphinReceive_97x61);
-
-    // Start worker
-    view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewPopup);
-    app->poller = nfc_poller_alloc(app->nfc, NfcProtocolMfClassic);
-    nfc_poller_start(app->poller, metroflip_scene_charlicard_poller_callback, app);
-
-    metroflip_app_blink_start(app);
-}
-
-bool metroflip_scene_charliecard_on_event(void* context, SceneManagerEvent event) {
-    Metroflip* app = context;
-    bool consumed = false;
-
-    if(event.type == SceneManagerEventTypeCustom) {
-        if(event.event == MetroflipCustomEventCardDetected) {
-            Popup* popup = app->popup;
-            popup_set_header(popup, "DON'T\nMOVE", 68, 30, AlignLeft, AlignTop);
-            consumed = true;
-        } else if(event.event == MetroflipCustomEventCardLost) {
-            Popup* popup = app->popup;
-            popup_set_header(popup, "Card \n lost", 68, 30, AlignLeft, AlignTop);
-            consumed = true;
-        } else if(event.event == MetroflipCustomEventWrongCard) {
-            Popup* popup = app->popup;
-            popup_set_header(popup, "WRONG \n CARD", 68, 30, AlignLeft, AlignTop);
-            consumed = true;
-        } else if(event.event == MetroflipCustomEventPollerFail) {
-            Popup* popup = app->popup;
-            popup_set_header(popup, "Failed", 68, 30, AlignLeft, AlignTop);
-            consumed = true;
-        }
-    } else if(event.type == SceneManagerEventTypeBack) {
-        scene_manager_search_and_switch_to_previous_scene(app->scene_manager, MetroflipSceneStart);
-        consumed = true;
-    }
-
-    return consumed;
-}
-
-void metroflip_scene_charliecard_on_exit(void* context) {
-    Metroflip* app = context;
-    widget_reset(app->widget);
-
-    if(app->poller) {
-        nfc_poller_stop(app->poller);
-        nfc_poller_free(app->poller);
-    }
-
-    // Clear view
-    popup_reset(app->popup);
-
-    metroflip_app_blink_stop(app);
-}
+/*
+ * Parser for MBTA CharlieCard (Boston, MA, USA).
+ *
+ * Copyright 2024 Zachary Weiss <me@zachary.ws>
+ * 
+ * Public security research on the MBTA's fare system stretches back to 2008,
+ * starting with Russel Ryan, Zack Anderson, and Alessandro Chiesa's 
+ * "Anatomy of a Subway Hack", for which they were famously issued a gag order. 
+ * A thorough history of research & researchers deserving of credit is 
+ * detailed by @bobbyrsec in his 2022 blog post (& presentation):
+ * "Operation Charlie: Hacking the MBTA CharlieCard from 2008 to Present" 
+ * https://medium.com/@bobbyrsec/operation-charlie-hacking-the-mbta-charliecard-from-2008-to-present-24ea9f0aaa38
+ * 
+ * Fare gate IDs, card types, and general assistance courtesy of the
+ * minds behind DEFCON 31's "Boston Infinite Money Glitch" presentation:
+ * — Matthew Harris; mattyharris.net <matty@mattyharris.net>
+ * — Zachary Bertocchi; zackbertocchi.com <zach@zachbertocci.com>
+ * — Scott Campbell; josephscottcampbell.com <scott@josephscottcampbell.com>
+ * — Noah Gibson; <noahgibson06@proton.me>
+ * Talk available at: https://www.youtube.com/watch?v=1JT_lTfK69Q
+ * 
+ * TODOs:
+ * — Reverse engineer passes (sectors 4 & 5?), impl.
+ * — Infer transaction flag meanings
+ * — Infer remaining unknown bytes in the balance sectors (2 & 3)
+ * — Improve string output formatting, esp. of transaction log
+ * — Mapping of buses to garages, and subsequently, route subsets via 
+ *   http://roster.transithistory.org/ data
+ * — Mapping of stations to lines
+ * — Add'l data fields for side of station fare gates are on? Some stations
+ *   separate inbound & outbound sides, so direction could be inferred
+ *   from gates used.
+ * — Continually gather data on fare gate ID mappings, update as collected;
+ *   check locations this might be scrapable / inferrable from:
+ *   [X] MBTA GTFS spec (https://www.mbta.com/developers/gtfs) features & IDs 
+ *       seem too-coarse-grained & uncorrelated
+ *   [X] MBTA ArcGIS (https://mbta-massdot.opendata.arcgis.com/) & Tableau 
+ *       (https://public.tableau.com/app/profile/mbta.office.of.performance.management.and.innovation/vizzes) 
+ *       files don't seem to have anything of that resolution (only down to ridership by station)
+ *   [X] (skim of) MBTA public GitHub (https://github.com/mbta) repos make no reference to fare-gate-level data
+ *   [X] (skim of) MBTA public engineering docs (https://www.mbta.com/engineering) unfruitful;
+ *       Closest mention spotted is 2014 "Ridership and Service Statistics" 
+ *       (https://cdn.mbta.com/sites/default/files/fmcb-meeting-docs/reports-policies/2014-07-mbta-bluebook-ed14.pdf)
+ *       where on pg.40, "Equipment at Stations" is enumerated, and fare gates counts are given,
+ *       listed as "AFC Gates" (presumably standing for "Automated Fare Collection")
+ *   [X] Josiah Zachery criminal trial public evidence — convicted partially on 
+ *       data on his CharlieCard, appeals partially on basis of legality of this search.
+ *       Prev. court case (gag order mentioned in preamble) leaked some data in the files
+ *       entered into evidence. Seemingly did not happen here; fare gate IDs unmentioned,
+ *       only ever the nature of stored/saved data and methods of retrieval.
+ *       Appelate case dockets 2019-P-0401, SJC-12952, SJ-2017-0390 
+ *       (https://www.ma-appellatecourts.org/party)
+ *       Trial court indictment 04/02/2015, Case# 1584CR10265 @Suffolk County Criminal Superior Court 
+ *       (https://www.masscourts.org/eservices/home.page.16)
+ *   [ ] FOIA / public records request? 
+ *       (https://massachusettsdot.mycusthelp.com/WEBAPP/_rs/(S(tbcygdlm0oojy35p1wv0y2y5))/supporthome.aspx)
+ *   [X] MBTA data blog? (https://www.massdottracker.com/datablog/)
+ *   [ ] MassDOT developers Google group? (https://groups.google.com/g/massdotdevelopers)
+ *       [X] preexisting posts
+ *       [ ] ask directly?
+ *   [ ] Other?
+ *
+ * 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 <http://www.gnu.org/licenses/>.
+ */
+
+#include <flipper_application.h>
+#include "../../metroflip_i.h"
+#include "../../metroflip_plugins.h"
+#include "../../api/metroflip/metroflip_api.h"
+#include <nfc/protocols/mf_classic/mf_classic_poller_sync.h>
+#include <nfc/protocols/mf_classic/mf_classic.h>
+#include <nfc/protocols/mf_classic/mf_classic_poller.h>
+#include <dolphin/dolphin.h>
+#include <bit_lib.h>
+#include <datetime.h>
+#include <furi_hal.h>
+#include <locale/locale.h>
+#include <nfc/nfc.h>
+#include <nfc/nfc_device.h>
+#include <nfc/nfc_listener.h>
+#include "../../api/metroflip/metroflip_api.h"
+
+#define TAG "Metroflip:Scene:CharlieCard"
+
+// starts Wednesday 2003/1/1 @ midnight
+#define CHARLIE_EPOCH          \
+    (DateTime) {               \
+        0, 0, 0, 1, 1, 2003, 4 \
+    }
+// timestep is one minute
+#define CHARLIE_TIME_DELTA_SECS       60
+#define CHARLIE_END_VALID_DELTA_SECS  60 * 8
+#define CHARLIE_N_TRANSACTION_HISTORY 10
+#define CHARLIE_N_PASSES              4
+
+// always from the same set of keys (cf. default keys dict for list w/o multiplicity)
+// we only care about the data in the first half of the sectors
+// second half sectors keys seemingly change position sometimes across cards?
+// no data stored there, but might want to impl some custom read function
+// accounting for this such that reading is faster (else it seems to fall back on dict
+// approach for remaining keys)...
+
+typedef struct {
+    uint16_t dollars;
+    uint8_t cents;
+} Money;
+
+// always from the same set of keys (cf. default keys dict for list w/o multiplicity)
+// we only care about the data in the first half of the sectors
+// second half sectors keys seemingly change position sometimes across cards?
+// no data stored there, but might want to impl some custom read function
+// accounting for this such that reading is faster (else it seems to fall back on dict
+// approach for remaining keys)...
+const MfClassicKeyPair charliecard_1k_keys[] = {
+    {.a = 0x3060206F5B0A, .b = 0xF1B9F5669CC8},
+    {.a = 0x5EC39B022F2B, .b = 0xF662248E7E89},
+    {.a = 0x5EC39B022F2B, .b = 0xF662248E7E89},
+    {.a = 0x5EC39B022F2B, .b = 0xF662248E7E89},
+    {.a = 0x5EC39B022F2B, .b = 0xF662248E7E89},
+    {.a = 0x5EC39B022F2B, .b = 0xF662248E7E89},
+    {.a = 0x5EC39B022F2B, .b = 0xF662248E7E89},
+    {.a = 0x5EC39B022F2B, .b = 0xF662248E7E89},
+    {.a = 0x3A09594C8587, .b = 0x62387B8D250D},
+    {.a = 0xF238D78FF48F, .b = 0x9DC282D46217},
+    {.a = 0xAFD0BA94D624, .b = 0x92EE4DC87191},
+    {.a = 0xB35A0E4ACC09, .b = 0x756EF55E2507},
+    {.a = 0x447AB7FD5A6B, .b = 0x932B9CB730EF},
+    {.a = 0x1F1A0A111B5B, .b = 0xAD9E0A1CA2F7},
+    {.a = 0xD58023BA2BDC, .b = 0x62CED42A6D87},
+    {.a = 0x2548A443DF28, .b = 0x2ED3B15E7C0F},
+};
+
+#define FARE_BUS \
+    (Money) {    \
+        1, 70    \
+    }
+#define FARE_SUB \
+    (Money) {    \
+        2, 40    \
+    }
+
+typedef struct {
+    DateTime date;
+    uint16_t gate;
+    uint8_t g_flag;
+    Money fare;
+    uint16_t f_flag;
+} Transaction;
+
+typedef struct {
+    bool valid;
+    uint16_t pre;
+    uint16_t post;
+    DateTime date;
+} Pass;
+
+typedef struct {
+    uint16_t n_uses;
+    uint8_t active_balance_sector;
+} CounterSector;
+
+typedef struct {
+    Money balance;
+    uint16_t type;
+    DateTime issued;
+    DateTime end_validity;
+} BalanceSector;
+
+// IdMapping approach borrowed from Jeremy Cooper's 'clipper.c'
+typedef struct {
+    uint16_t id;
+    const char* name;
+} IdMapping;
+
+// this should be a complete accounting of types, (1 and 7 day pass types maybe missing?)
+static const IdMapping charliecard_types[] = {
+    // Regular card types
+    {.id = 367, .name = "Adult"},
+    {.id = 366, .name = "SV Adult"},
+    {.id = 418, .name = "Student"},
+    {.id = 419, .name = "Senior"},
+    {.id = 420, .name = "TAP"},
+    {.id = 417, .name = "Blind"},
+    {.id = 426, .name = "Child"},
+    {.id = 410, .name = "Employee ID Without Passback"},
+    {.id = 414, .name = "Employee ID With Passback"},
+    {.id = 415, .name = "Retiree"},
+    {.id = 416, .name = "Police/Fire"},
+
+    // Passes
+    {.id = 135, .name = "30 Day Local Bus Pass"},
+    {.id = 136, .name = "30 Day Inner Express Bus Pass"},
+    {.id = 137, .name = "30 Day Outer Express Bus Pass"},
+    {.id = 138, .name = "30 Day LinkPass"},
+    {.id = 139, .name = "30 Day Senior LinkPass"},
+    {.id = 148, .name = "30 Day TAP LinkPass"},
+    {.id = 150, .name = "Monthly Student LinkPass"},
+    {.id = 424, .name = "Monthly TAP LinkPass"},
+    {.id = 425, .name = "Monthly Senior LinkPass"},
+    {.id = 421, .name = "Senior TAP/Permit"},
+    {.id = 422, .name = "Senior TAP/Permit 30 Days"},
+
+    // Commuter rail passes
+    {.id = 166, .name = "30 Day Commuter Rail Zone 1A Pass"},
+    {.id = 167, .name = "30 Day Commuter Rail Zone 1 Pass"},
+    {.id = 168, .name = "30 Day Commuter Rail Zone 2 Pass"},
+    {.id = 169, .name = "30 Day Commuter Rail Zone 3 Pass"},
+    {.id = 170, .name = "30 Day Commuter Rail Zone 4 Pass"},
+    {.id = 171, .name = "30 Day Commuter Rail Zone 5 Pass"},
+    {.id = 172, .name = "30 Day Commuter Rail Zone 6 Pass"},
+    {.id = 173, .name = "30 Day Commuter Rail Zone 7 Pass"},
+    {.id = 174, .name = "30 Day Commuter Rail Zone 8 Pass"},
+    {.id = 175, .name = "30 Day Interzone 1 Pass"},
+    {.id = 176, .name = "30 Day Interzone 2 Pass"},
+    {.id = 177, .name = "30 Day Interzone 3 Pass"},
+    {.id = 178, .name = "30 Day Interzone 4 Pass"},
+    {.id = 179, .name = "30 Day Interzone 5 Pass"},
+    {.id = 180, .name = "30 Day Interzone 6 Pass"},
+    {.id = 181, .name = "30 Day Interzone 7 Pass"},
+    {.id = 182, .name = "30 Day Interzone 8 Pass"},
+
+    {.id = 140, .name = "One Way Interzone Adult 1 Zone"},
+    {.id = 141, .name = "One Way Interzone Adult 2 Zones"},
+    {.id = 142, .name = "One Way Interzone Adult 3 Zones"},
+    {.id = 143, .name = "One Way Interzone Adult 4 Zones"},
+    {.id = 144, .name = "One Way Interzone Adult 5 Zones"},
+    {.id = 145, .name = "One Way Interzone Adult 6 Zones"},
+    {.id = 146, .name = "One Way Interzone Adult 7 Zones"},
+    {.id = 147, .name = "One Way Interzone Adult 8 Zones"},
+
+    {.id = 428, .name = "One Way Half Fare Zone 1"},
+    {.id = 429, .name = "One Way Half Fare Zone 2"},
+    {.id = 430, .name = "One Way Half Fare Zone 3"},
+    {.id = 431, .name = "One Way Half Fare Zone 4"},
+    {.id = 432, .name = "One Way Half Fare Zone 5"},
+    {.id = 433, .name = "One Way Half Fare Zone 6"},
+    {.id = 434, .name = "One Way Half Fare Zone 7"},
+    {.id = 435, .name = "One Way Half Fare Zone 8"},
+    {.id = 436, .name = "One Way Interzone Half Fare 1 Zone"},
+    {.id = 437, .name = "One Way Interzone Half Fare 2 Zones"},
+    {.id = 438, .name = "One Way Interzone Half Fare 3 Zones"},
+    {.id = 439, .name = "One Way Interzone Half Fare 4 Zones"},
+    {.id = 440, .name = "One Way Interzone Half Fare 5 Zones"},
+    {.id = 441, .name = "One Way Interzone Half Fare 6 Zones"},
+    {.id = 442, .name = "One Way Interzone Half Fare 7 Zones"},
+    {.id = 443, .name = "One Way Interzone Half Fare 8 Zones"},
+
+    {.id = 509, .name = "Group Interzone 1 Zones"},
+    {.id = 510, .name = "Group Interzone 2 Zones"},
+    {.id = 511, .name = "Group Interzone 3 Zones"},
+    {.id = 512, .name = "Group Interzone 4 Zones"},
+    {.id = 513, .name = "Group Interzone 5 Zones"},
+    {.id = 514, .name = "Group Interzone 6 Zones"},
+    {.id = 515, .name = "Group Interzone 7 Zones"},
+    {.id = 516, .name = "Group Interzone 8 Zones"},
+
+    {.id = 952, .name = "Zone 1 Student Monthly Pass"},
+    {.id = 953, .name = "Zone 2 Student Monthly Pass"},
+    {.id = 954, .name = "Zone 3 Student Monthly Pass"},
+    {.id = 955, .name = "Zone 4 Student Monthly Pass"},
+    {.id = 956, .name = "Zone 5 Student Monthly Pass"},
+    {.id = 957, .name = "Zone 6 Student Monthly Pass"},
+    {.id = 958, .name = "Zone 7 Student Monthly Pass"},
+    {.id = 959, .name = "Zone 8 Student Monthly Pass"},
+    {.id = 960, .name = "Zone 9 Student Monthly Pass"},
+    {.id = 961, .name = "Zone 10 Student Monthly Pass"},
+
+    {.id = 963, .name = "Interzone 1 Zone Student Monthly Pass"},
+    {.id = 964, .name = "Interzone 2 Zone Student Monthly Pass"},
+    {.id = 965, .name = "Interzone 3 Zone Student Monthly Pass"},
+    {.id = 966, .name = "Interzone 4 Zone Student Monthly Pass"},
+    {.id = 967, .name = "Interzone 5 Zone Student Monthly Pass"},
+    {.id = 968, .name = "Interzone 6 Zone Student Monthly Pass"},
+    {.id = 969, .name = "Interzone 7 Zone Student Monthly Pass"},
+    {.id = 970, .name = "Interzone 8 Zone Student Monthly Pass"},
+    {.id = 971, .name = "Interzone 9 Zone Student Monthly Pass"},
+    {.id = 972, .name = "Interzone 10 Zone Student Monthly Pass"},
+};
+static const size_t kNumTypes = COUNT_OF(charliecard_types);
+
+// Incomplete, and subject to change
+// Only covers Orange & Blue line stations
+// Gathered manually, and provided courtesy of, DEFCON31 researchers
+// as cited above.
+static const IdMapping charliecard_fare_gate_ids[] = {
+    // Davis
+    {.id = 6766, .name = "Davis"},
+    {.id = 6767, .name = "Davis"},
+    {.id = 6768, .name = "Davis"},
+    {.id = 6769, .name = "Davis"},
+    {.id = 6770, .name = "Davis"},
+    {.id = 6771, .name = "Davis"},
+    {.id = 6772, .name = "Davis"},
+    {.id = 2167, .name = "Davis"},
+    {.id = 7020, .name = "Davis"},
+    // Porter
+    {.id = 6781, .name = "Porter"},
+    {.id = 6780, .name = "Porter"},
+    {.id = 6779, .name = "Porter"},
+    {.id = 6778, .name = "Porter"},
+    {.id = 6777, .name = "Porter"},
+    {.id = 6776, .name = "Porter"},
+    {.id = 6775, .name = "Porter"},
+    {.id = 2168, .name = "Porter"},
+    {.id = 7021, .name = "Porter"},
+    {.id = 6782, .name = "Porter"},
+    // Oak Grove
+    {.id = 6640, .name = "Oak Grove"},
+    {.id = 6641, .name = "Oak Grove"},
+    {.id = 6639, .name = "Oak Grove"},
+    {.id = 2036, .name = "Oak Grove"},
+    {.id = 6642, .name = "Oak Grove"},
+    {.id = 6979, .name = "Oak Grove"},
+    // Downtown Crossing
+    {.id = 2091, .name = "Downtown Crossing"},
+    {.id = 6995, .name = "Downtown Crossing"},
+    {.id = 6699, .name = "Downtown Crossing"},
+    {.id = 6700, .name = "Downtown Crossing"},
+    {.id = 1926, .name = "Downtown Crossing"},
+    {.id = 2084, .name = "Downtown Crossing"},
+    {.id = 6994, .name = "Downtown Crossing"},
+    {.id = 6695, .name = "Downtown Crossing"},
+    {.id = 6694, .name = "Downtown Crossing"},
+    {.id = 6696, .name = "Downtown Crossing"},
+    {.id = 2336, .name = "Downtown Crossing"},
+    {.id = 1056, .name = "Downtown Crossing"},
+    {.id = 6814, .name = "Downtown Crossing"},
+    {.id = 6813, .name = "Downtown Crossing"},
+    {.id = 2212, .name = "Downtown Crossing"},
+    {.id = 7038, .name = "Downtown Crossing"},
+    // State
+    {.id = 7092, .name = "State"},
+    {.id = 1844, .name = "State"},
+    {.id = 6689, .name = "State"},
+    {.id = 6988, .name = "State"},
+    {.id = 6991, .name = "State"},
+    {.id = 2083, .name = "State"},
+    {.id = 6688, .name = "State"},
+    {.id = 6687, .name = "State"},
+    {.id = 6686, .name = "State"},
+    {.id = 2078, .name = "State"},
+    {.id = 6987, .name = "State"},
+    {.id = 7090, .name = "State"},
+    {.id = 1842, .name = "State"},
+    // Haymarket
+    {.id = 6684, .name = "Haymarket"},
+    {.id = 6683, .name = "Haymarket"},
+    {.id = 6682, .name = "Haymarket"},
+    {.id = 6681, .name = "Haymarket"},
+    {.id = 2073, .name = "Haymarket"},
+    {.id = 7074, .name = "Haymarket"},
+    {.id = 6883, .name = "Haymarket"},
+    {.id = 6884, .name = "Haymarket"},
+    {.id = 6885, .name = "Haymarket"},
+    {.id = 6886, .name = "Haymarket"},
+    {.id = 2303, .name = "Haymarket"},
+    {.id = 6986, .name = "Haymarket"},
+    // North Station
+    {.id = 6985, .name = "North Station"},
+    {.id = 2063, .name = "North Station"},
+    {.id = 6671, .name = "North Station"},
+    {.id = 6672, .name = "North Station"},
+    {.id = 6673, .name = "North Station"},
+    {.id = 6674, .name = "North Station"},
+    {.id = 6675, .name = "North Station"},
+    {.id = 6676, .name = "North Station"},
+    {.id = 6677, .name = "North Station"},
+    {.id = 6678, .name = "North Station"},
+    {.id = 6984, .name = "North Station"},
+    {.id = 2062, .name = "North Station"},
+    {.id = 6668, .name = "North Station"},
+    {.id = 6667, .name = "North Station"},
+    {.id = 6666, .name = "North Station"},
+    {.id = 6665, .name = "North Station"},
+    {.id = 6664, .name = "North Station"},
+    // Sullivan Square
+    {.id = 6654, .name = "Sullivan Square"},
+    {.id = 6655, .name = "Sullivan Square"},
+    {.id = 6656, .name = "Sullivan Square"},
+    {.id = 6657, .name = "Sullivan Square"},
+    {.id = 6658, .name = "Sullivan Square"},
+    {.id = 6659, .name = "Sullivan Square"},
+    {.id = 2053, .name = "Sullivan Square"},
+    {.id = 6982, .name = "Sullivan Square"},
+    // Community College
+    {.id = 6661, .name = "Community College"},
+    {.id = 6662, .name = "Community College"},
+    {.id = 2056, .name = "Community College"},
+    {.id = 6983, .name = "Community College"},
+    // Assembly
+    {.id = 3876, .name = "Assembly"},
+    {.id = 3875, .name = "Assembly"},
+    {.id = 6957, .name = "Assembly"},
+    {.id = 6956, .name = "Assembly"},
+    {.id = 6955, .name = "Assembly"},
+    {.id = 6954, .name = "Assembly"},
+    {.id = 6953, .name = "Assembly"},
+    {.id = 7101, .name = "Assembly"},
+    {.id = 3873, .name = "Assembly"},
+    {.id = 3872, .name = "Assembly"},
+    // Wellington
+    {.id = 6981, .name = "Wellington"},
+    {.id = 2042, .name = "Wellington"},
+    {.id = 6650, .name = "Wellington"},
+    {.id = 6651, .name = "Wellington"},
+    {.id = 6652, .name = "Wellington"},
+    {.id = 6653, .name = "Wellington"},
+    // Malden
+    {.id = 6980, .name = "Malden Center"},
+    {.id = 2037, .name = "Malden Center"},
+    {.id = 6645, .name = "Malden Center"},
+    {.id = 6646, .name = "Malden Center"},
+    {.id = 6647, .name = "Malden Center"},
+    {.id = 6648, .name = "Malden Center"},
+    // Chinatown
+    {.id = 6704, .name = "Chinatown"},
+    {.id = 6705, .name = "Chinatown"},
+    {.id = 2099, .name = "Chinatown"},
+    {.id = 7003, .name = "Chinatown"},
+    {.id = 7002, .name = "Chinatown"},
+    {.id = 2096, .name = "Chinatown"},
+    {.id = 6702, .name = "Chinatown"},
+    {.id = 6701, .name = "Chinatown"},
+    // Tufts Medical Center
+    {.id = 6707, .name = "Tufts Medical Center"},
+    {.id = 6708, .name = "Tufts Medical Center"},
+    {.id = 6709, .name = "Tufts Medical Center"},
+    {.id = 6710, .name = "Tufts Medical Center"},
+    {.id = 6711, .name = "Tufts Medical Center"},
+    {.id = 2105, .name = "Tufts Medical Center"},
+    {.id = 7004, .name = "Tufts Medical Center"},
+    {.id = 1941, .name = "Tufts Medical Center"},
+    {.id = 7006, .name = "Tufts Medical Center"},
+    // Back Bay
+    {.id = 7007, .name = "Back Bay"},
+    {.id = 1480, .name = "Back Bay"},
+    {.id = 6714, .name = "Back Bay"},
+    {.id = 6715, .name = "Back Bay"},
+    {.id = 6716, .name = "Back Bay"},
+    {.id = 6717, .name = "Back Bay"},
+    {.id = 6718, .name = "Back Bay"},
+    {.id = 6719, .name = "Back Bay"},
+    {.id = 6720, .name = "Back Bay"},
+    {.id = 1801, .name = "Back Bay"},
+    {.id = 7009, .name = "Back Bay"},
+    // Massachusetts Avenue
+    {.id = 7010, .name = "Massachusetts Avenue"},
+    {.id = 2118, .name = "Massachusetts Avenue"},
+    {.id = 6724, .name = "Massachusetts Avenue"},
+    {.id = 6723, .name = "Massachusetts Avenue"},
+    {.id = 6722, .name = "Massachusetts Avenue"},
+    {.id = 6721, .name = "Massachusetts Avenue"},
+    // Ruggles
+    {.id = 6726, .name = "Ruggles"},
+    {.id = 6727, .name = "Ruggles"},
+    {.id = 6728, .name = "Ruggles"},
+    {.id = 2122, .name = "Ruggles"},
+    {.id = 2123, .name = "Ruggles"},
+    {.id = 2124, .name = "Ruggles"},
+    {.id = 1804, .name = "Ruggles"},
+    // Roxbury Crossing
+    {.id = 6737, .name = "Roxbury Crossing"},
+    {.id = 6736, .name = "Roxbury Crossing"},
+    {.id = 6735, .name = "Roxbury Crossing"},
+    {.id = 6734, .name = "Roxbury Crossing"},
+    {.id = 6733, .name = "Roxbury Crossing"},
+    {.id = 2125, .name = "Roxbury Crossing"},
+    {.id = 7012, .name = "Roxbury Crossing"},
+    // Jackson Square
+    {.id = 6741, .name = "Jackson Square"},
+    {.id = 6740, .name = "Jackson Square"},
+    {.id = 6739, .name = "Jackson Square"},
+    {.id = 2131, .name = "Jackson Square"},
+    {.id = 7013, .name = "Jackson Square"},
+    {.id = 7014, .name = "Jackson Square"},
+    {.id = 2135, .name = "Jackson Square"},
+    {.id = 6743, .name = "Jackson Square"},
+    {.id = 6744, .name = "Jackson Square"},
+    {.id = 6745, .name = "Jackson Square"},
+    // Green Street
+    {.id = 6746, .name = "Green Street"},
+    {.id = 6747, .name = "Green Street"},
+    {.id = 6748, .name = "Green Street"},
+    {.id = 2142, .name = "Green Street"},
+    {.id = 7015, .name = "Green Street"},
+    // Forest Hills
+    {.id = 6750, .name = "Forest Hills"},
+    {.id = 6751, .name = "Forest Hills"},
+    {.id = 6752, .name = "Forest Hills"},
+    {.id = 6753, .name = "Forest Hills"},
+    {.id = 6754, .name = "Forest Hills"},
+    {.id = 6755, .name = "Forest Hills"},
+    {.id = 2150, .name = "Forest Hills"},
+    {.id = 7016, .name = "Forest Hills"},
+    {.id = 6950, .name = "Forest Hills"},
+    {.id = 6951, .name = "Forest Hills"},
+    {.id = 604, .name = "Forest Hills"},
+    {.id = 7096, .name = "Forest Hills"},
+    // South Station
+    {.id = 7039, .name = "South Station"},
+    {.id = 2215, .name = "South Station"},
+    {.id = 6816, .name = "South Station"},
+    {.id = 6817, .name = "South Station"},
+    {.id = 6818, .name = "South Station"},
+    {.id = 6819, .name = "South Station"},
+    {.id = 6820, .name = "South Station"},
+    {.id = 6821, .name = "South Station"},
+    {.id = 6822, .name = "South Station"},
+    {.id = 6823, .name = "South Station"},
+    {.id = 7040, .name = "South Station"},
+    {.id = 2228, .name = "South Station"},
+    {.id = 6827, .name = "South Station"},
+    {.id = 6826, .name = "South Station"},
+    {.id = 6825, .name = "South Station"},
+    {.id = 6824, .name = "South Station"},
+    // Courthouse
+    {.id = 6929, .name = "Courthouse"},
+    {.id = 2357, .name = "Courthouse"},
+    {.id = 7079, .name = "Courthouse"},
+    {.id = 6933, .name = "Courthouse"},
+    {.id = 6932, .name = "Courthouse"},
+    {.id = 2358, .name = "Courthouse"},
+    {.id = 6792, .name = "Courthouse"},
+    // Bowdoin
+    {.id = 6937, .name = "Bowdoin"},
+    {.id = 2367, .name = "Bowdoin"},
+    {.id = 7085, .name = "Bowdoin"},
+    // Government Center
+    {.id = 6963, .name = "Government Center"},
+    {.id = 6962, .name = "Government Center"},
+    {.id = 6961, .name = "Government Center"},
+    {.id = 6960, .name = "Government Center"},
+    {.id = 6959, .name = "Government Center"},
+    {.id = 6958, .name = "Government Center"},
+    {.id = 5298, .name = "Government Center"},
+    // Aquarium
+    {.id = 6609, .name = "Aquarium"},
+    {.id = 6608, .name = "Aquarium"},
+    {.id = 1877, .name = "Aquarium"},
+    {.id = 6965, .name = "Aquarium"},
+    {.id = 6610, .name = "Aquarium"},
+    {.id = 1880, .name = "Aquarium"},
+    {.id = 1871, .name = "Aquarium"},
+    {.id = 6966, .name = "Aquarium"},
+    // Maverick
+    {.id = 7088, .name = "Maverick"},
+    {.id = 6944, .name = "Maverick"},
+    {.id = 4384, .name = "Maverick"},
+    {.id = 6946, .name = "Maverick"},
+    {.id = 6947, .name = "Maverick"},
+    {.id = 6948, .name = "Maverick"},
+    {.id = 6949, .name = "Maverick"},
+    {.id = 1840, .name = "Maverick"},
+    {.id = 7083, .name = "Maverick"},
+    // Airport
+    {.id = 6613, .name = "Airport"},
+    {.id = 6612, .name = "Airport"},
+    {.id = 6611, .name = "Airport"},
+    {.id = 6968, .name = "Airport"},
+    {.id = 2009, .name = "Airport"},
+    {.id = 6616, .name = "Airport"},
+    {.id = 6615, .name = "Airport"},
+    {.id = 6614, .name = "Airport"},
+    {.id = 6970, .name = "Airport"},
+    {.id = 1847, .name = "Airport"},
+    // Wood Island
+    {.id = 6618, .name = "Wood Island"},
+    {.id = 6619, .name = "Wood Island"},
+    {.id = 2010, .name = "Wood Island"},
+    {.id = 6971, .name = "Wood Island"},
+    // Orient Heights
+    {.id = 6621, .name = "Orient Heights"},
+    {.id = 6622, .name = "Orient Heights"},
+    {.id = 6623, .name = "Orient Heights"},
+    {.id = 2014, .name = "Orient Heights"},
+    {.id = 6972, .name = "Orient Heights"},
+    {.id = 6974, .name = "Orient Heights"},
+    {.id = 1868, .name = "Orient Heights"},
+    // Suffolk Downs
+    {.id = 6625, .name = "Suffolk Downs"},
+    {.id = 6626, .name = "Suffolk Downs"},
+    {.id = 2017, .name = "Suffolk Downs"},
+    {.id = 6975, .name = "Suffolk Downs"},
+    // Beachmont
+    {.id = 6628, .name = "Beachmont"},
+    {.id = 6629, .name = "Beachmont"},
+    {.id = 6630, .name = "Beachmont"},
+    {.id = 2021, .name = "Beachmont"},
+    {.id = 6976, .name = "Beachmont"},
+    // Revere Beach
+    {.id = 6632, .name = "Revere Beach"},
+    {.id = 6633, .name = "Revere Beach"},
+    {.id = 2024, .name = "Revere Beach"},
+    {.id = 6977, .name = "Revere Beach"},
+    // Wonderland
+    {.id = 6638, .name = "Wonderland"},
+    {.id = 6637, .name = "Wonderland"},
+    {.id = 6636, .name = "Wonderland"},
+    {.id = 2025, .name = "Wonderland"},
+    {.id = 6978, .name = "Wonderland"},
+};
+static const size_t kNumFareGateIds = COUNT_OF(charliecard_fare_gate_ids);
+
+// **********************************************************
+// ********************* MISC HELPERS ***********************
+// **********************************************************
+
+static const uint8_t*
+    pos_to_ptr(const MfClassicData* data, uint8_t sector_num, uint8_t block_num, uint8_t byte_num) {
+    // returns pointer to specified sector/block/byte of MFClassic card data
+    uint8_t block_offset = mf_classic_get_first_block_num_of_sector(sector_num);
+    return &data->block[block_offset + block_num].data[byte_num];
+}
+
+static uint64_t pos_to_num(
+    const MfClassicData* data,
+    uint8_t sector_num,
+    uint8_t block_num,
+    uint8_t byte_num,
+    uint8_t byte_len) {
+    // returns numeric values at specified card location, for given byte length.
+    // assumes big endian.
+    return bit_lib_bytes_to_num_be(pos_to_ptr(data, sector_num, block_num, byte_num), byte_len);
+}
+
+static DateTime dt_delta(DateTime dt, uint64_t delta_secs) {
+    // returns shifted DateTime, from initial DateTime and time offset in seconds
+    DateTime dt_shifted = {0};
+    datetime_timestamp_to_datetime(datetime_datetime_to_timestamp(&dt) + delta_secs, &dt_shifted);
+
+    return dt_shifted;
+}
+
+static bool dt_ge(DateTime dt1, DateTime dt2) {
+    // compares two DateTimes
+    return datetime_datetime_to_timestamp(&dt1) >= datetime_datetime_to_timestamp(&dt2);
+}
+
+static bool dt_eq(DateTime dt1, DateTime dt2) {
+    // compares two DateTimes
+    return datetime_datetime_to_timestamp(&dt1) == datetime_datetime_to_timestamp(&dt2);
+}
+
+static bool get_map_item(uint16_t id, const IdMapping* map, size_t sz, const char** out) {
+    // code borrowed from Jeremy Cooper's 'clipper.c'. Used as follows:
+    // const char* s; if(!get_map_item(_,_,_,&s)) {s="Default str";}
+    // TODO: change to furistring out?
+    for(size_t i = 0; i < sz; i++) {
+        if(map[i].id == id) {
+            *out = map[i].name;
+            return true;
+        }
+    }
+
+    return false;
+}
+
+uint32_t time_now() {
+    return furi_hal_rtc_get_timestamp();
+}
+
+static bool is_debug() {
+    return furi_hal_rtc_is_flag_set(FuriHalRtcFlagDebug);
+}
+
+// **********************************************************
+// ******************** FIELD PARSING ***********************
+// **********************************************************
+
+static Money money_parse(
+    const MfClassicData* data,
+    uint8_t sector_num,
+    uint8_t block_num,
+    uint8_t byte_num) {
+    // CharlieCards store all money values in two bytes as half-cents
+    // bitmask removes sign/flag, bitshift converts half-cents to cents, div & mod yield dollars & cents
+    uint16_t amt = (pos_to_num(data, sector_num, block_num, byte_num, 2) & 0x7FFF) >> 1;
+    return (Money){amt / 100, amt % 100};
+}
+
+static DateTime
+    date_parse(const MfClassicData* data, uint8_t sector_num, uint8_t block_num, uint8_t byte_num) {
+    // Dates are 3 bytes, in minutes since 2003/1/1 ("CHARLIE_EPOCH")
+    uint32_t ts_charlie = pos_to_num(data, sector_num, block_num, byte_num, 3);
+    return dt_delta(CHARLIE_EPOCH, ts_charlie * CHARLIE_TIME_DELTA_SECS);
+}
+
+static DateTime end_validity_parse(
+    const MfClassicData* data,
+    uint8_t sector_num,
+    uint8_t block_num,
+    uint8_t byte_num) {
+    // End validity field is weird; shares first byte with another variable (the card type field),
+    // occupying the last 5 bits (and subsequent two bytes), hence bitmask
+    uint32_t ts_charlie_ev = pos_to_num(data, sector_num, block_num, byte_num, 3) & 0x1FFFFF;
+
+    // additionally, instead of minute deltas, is in 8 minute increments
+    // relative to CHARLIE_EPOCH (2003/1/1), per DEFCON31 researcher's work
+    return dt_delta(CHARLIE_EPOCH, ts_charlie_ev * CHARLIE_END_VALID_DELTA_SECS);
+}
+
+static Pass
+    pass_parse(const MfClassicData* data, uint8_t sector_num, uint8_t block_num, uint8_t byte_num) {
+    // WIP; testing only. Speculating it may be structured as follows
+    // Sub-byte field divisions not drawn to scale, see code for exact bit offsets
+    //
+    //       0    1    2    3    4    5
+    //       +----.----.----.----+----.----+
+    //       |  uk1  |   date    |   uk2   |
+    //       +----.----.----.----+----.----+
+    //
+    // "Blank" entries are as follows:
+    //       0    1    2    3    4    5
+    //       +----.----.----.----.----.----+
+    //       | 00   20   00   00   00   00 |
+    //       +----.----.----.----.----.----+
+    //
+    // even when not blank, uk1 LSB seems to always be set to 1...
+    // the sole bit set to 1 on the blank entry seems to divide
+    // the uk1 and date fields, and is always set to 1 regardless
+    // same is true of type & end-validity split found in balance sector
+    //
+    // likely fields incl
+    // — type #,
+    // — a secondary date field (eg start/end, end validity or normal format)
+    // — ID of FVM from which the pass was loaded
+
+    // check for empty, if so, return struct filled w/ 0s
+    // (incl "valid" field: hence, "valid" is false-y)
+    if(pos_to_num(data, sector_num, block_num, byte_num, 6) == 0x002000000000) {
+        return (Pass){0};
+    }
+
+    // const DateTime start = date_parse(data, sector_num, block_num, byte_num + 1);
+
+    const uint16_t pre = pos_to_num(data, sector_num, block_num, byte_num, 2) >> 6;
+    const uint16_t post = (pos_to_num(data, sector_num, block_num, byte_num + 4, 2) >> 2) & 0x3ff;
+
+    // these values make sense for a date, but implied position of type
+    // before end validity, as seen in balance sector, doesn't seem
+    // to produce sensible values
+    const DateTime date = end_validity_parse(data, sector_num, block_num, byte_num + 1);
+
+    // DateTime start = date_parse(data, sector_num, block_num, byte_num);
+    // uint16_t type = 0; // pos_to_num(data, sector_num, block_num, byte_num + 3, 2) >> 6;
+
+    return (Pass){true, pre, post, date};
+}
+
+static Transaction
+    transaction_parse(const MfClassicData* data, uint8_t sector, uint8_t block, uint8_t byte) {
+    // This function parses individual transactions. Each transaction packs 7 bytes, stored as follows:
+    //
+    //       0    1    2    3    4    5    6
+    //       +----.----.----+----.--+-+----.----+
+    //       |     date     |   loc |f|   amt   |
+    //       +----.----.----+----.--+-+----.----+
+    //
+    // Where date is in the typical format, loc represents the fare gate tapped, and amt is the fare amount.
+    // Amount appears to contain some flag bits, however, it is unclear what precisely their function is.
+    //
+    // Gate ID ("loc") is only the first 13 bits of 0x3:0x5, the final three bits appear to be flags ("f").
+    // Least significant flag bit seems to indicate:
+    // — When f & 1 == 1, fare (the amount by which balance is decremented)
+    // — When f & 1 == 0, refill (the amount by which balance is incremented)
+    // MSB (sign bit) of amt seems to serve the same role, just inverted, ie
+    // — When amt & 0x8000 == 0, fare
+    // — When amt & 0x8000 == 0x8000, refill
+    // Only contradiction between the two observed is on cards w/ passes;
+    // MSB of amt seems to be set for every transaction when (remaining bits of) amt is 0 on a card w/ a pass
+    // Hence, using f's LSB as method for inferring fare v. refill
+    //
+    // Remaining unknown bits:
+    // — f & 0b100; seems to be set on fares where the card has a pass, and amt is 0
+    // — f & 0b010
+    // — amt & 1; does not seem to correspond with card type, last transaction, first transaction, refill v. fare, etc
+
+    const DateTime date = date_parse(data, sector, block, byte);
+    const uint16_t gate = pos_to_num(data, sector, block, byte + 3, 2) >> 3;
+    const uint8_t g_flag = pos_to_num(data, sector, block, byte + 3, 2) & 0b111;
+    const Money fare = money_parse(data, sector, block, byte + 5);
+    const uint16_t f_flag = pos_to_num(data, sector, block, byte + 5, 2) & 0x8001;
+    return (Transaction){date, gate, g_flag, fare, f_flag};
+}
+
+// **********************************************************
+// ******************* SECTOR PARSING ***********************
+// **********************************************************
+
+static uint32_t mfg_sector_parse(const MfClassicData* data) {
+    // Manufacturer data (Sector 0)
+    //
+    //       0    1    2    3    4    5    6    7    8    9    A    B    C    D    E    F
+    //       +----.----.----.----+----+----.----.----.----+----+----.----.----.----.----+----+
+    // 0x000 |        UID        | rc | 88   04   00   C8 | uk | 00   20   00   00   00 | uk |
+    //       +----.----.----.----+----+----.----.----.----+----+----.----.----.----.----+----+
+    // 0x010 | 4E   0F   04   10   04   10   04   10   04   10   04   10   04   10   04   10 |
+    //       +----.----.----.----.----.----.----.----.----.----.----.----.----.----.----.----+
+    // 0x020 |                               ...  00   00  ...                               |
+    //       +----.----.----.----.----.----.----.----.----.----.----.----.----.----.----.----+
+    //
+    // rc := "redundancy check" (lrc / bcc)
+    // uk := "unknown"
+
+    size_t uid_len = 0;
+    const uint8_t* uid = mf_classic_get_uid(data, &uid_len);
+    const uint32_t card_number = bit_lib_bytes_to_num_be(uid, 4);
+
+    return card_number;
+}
+
+static CounterSector counter_sector_parse(const MfClassicData* data) {
+    // Trip/transaction counters (Sector 1)
+    //
+    //       0    1    2    3    4    5    6    7    8    9    A    B    C    D    E    F
+    //       +----.----.----.----.----.----.----.----.----.----.----.----.----.----.----.----+
+    // 0x040 | 04   10   23   45   66   77                  ...  00   00  ...                |
+    //       +----.----+----+----.----.----.----.----.----.----.----.----.----.----.----.----+
+    // 0x050 |  uses1  | uk |                     ...  00   00  ...                          |
+    //       +----.----+----+----.----.----.----.----.----.----.----.----.----.----.----.----+
+    // 0x060 |  uses2  | uk |                     ...  00   00  ...                          |
+    //       +----.----+----+----.----.----.----.----.----.----.----.----.----.----.----.----+
+    //
+    // uk := "unknown"; if nonzero, seems to only occupy the first 4 bits (ie, uk & 0xF0 == uk),
+    //        with the remaining 4 zero
+
+    // Card has two sectors (2 & 3) containing balance data, with two
+    // corresponding trip counters in 0x50:0x51 & 0x60:0x61 (sector 1, byte 0:1 of blocks 1 & 2).
+
+    // The *lower* of the two values *minus one* is the true use count,
+    // and corresponds to the active balance sector,
+    // (0x50 counter lower -> sector 2 active, 0x60 counter lower -> 3 active)
+    // per DEFCON31 researcher's findings
+
+    const uint16_t n_uses1 = pos_to_num(data, 1, 1, 0, 2);
+    const uint16_t n_uses2 = pos_to_num(data, 1, 2, 0, 2);
+
+    const bool is_sec2_active = n_uses1 <= n_uses2;
+    const uint8_t active_sector = is_sec2_active ? 2 : 3;
+    const uint16_t n_uses = (is_sec2_active ? n_uses1 : n_uses2) - 1;
+
+    return (CounterSector){n_uses, active_sector};
+}
+
+static BalanceSector balance_sector_parse(const MfClassicData* data, uint8_t active_sector) {
+    // Balance & misc card info (Sector 2 or 3)
+    //
+    //       0    1    2    3    4    5    6    7    8    9    A    B    C    D    E    F
+    //       +----+----.----.----+----.----+----.----.----+----.----+----.----+----+----.----+
+    // 0x080 | 11 |   date last  | loc last| date issued  | 65   00 | unknown | 00 |   crc   | 0x0C0
+    //       +----+----.----.----+----+----+----+----+----+----.----+----.----+----+----.----+
+    // 0x090 | type |end validity| uk | balance | 00 |           unknown           |   crc   | 0x0D0
+    //       +----.----.----.----+----+----.----+----+----.----.----.----.----.----+----.----+
+    // 0x0A0 |      20             ...  00   00  ...             04                |   crc   | 0x0E0
+    //       +----.----.----.----.----.----.----.----.----.----.----.----.----.----+----.----+
+    //
+    // "Active" balance sector alternates between 2 and 3
+    // Last trip/transaction info in balance sector ("date last" & "loc last")
+    // is also included in transaction log, hence don't bother to read here
+    //
+    // Inactive balance sector represent the transaction N-1 version
+    // (where active sector represents data from transaction N).
+
+    const DateTime issued = date_parse(data, active_sector, 0, 6);
+    const DateTime end_validity = end_validity_parse(data, active_sector, 1, 1);
+    // Card type data stored in the first 10bits of block 1
+    // (0x90 or 0xD0 depending on active sector)
+    // bitshift (2bytes = 16 bits) by 6bits for just first 10bits
+    const uint16_t type = pos_to_num(data, active_sector, 1, 0, 2) >> 6;
+    const Money bal = money_parse(data, active_sector, 1, 5);
+
+    return (BalanceSector){bal, type, issued, end_validity};
+}
+
+static Pass* passes_parse(const MfClassicData* data) {
+    // Passes, speculative (Sectors 4 &/or 5)
+    //
+    //       0    1    2    3    4    5    6    7    8    9    A    B    C    D    E    F
+    //       +----.----.----.----.----.----+----+----.----.----.----.----.----+----+----.----+
+    // 0x100 |           pass0/2?          | 00 |           pass1/3?          | 00 |   crc   | 0x140
+    //       +----.----.----.----.----.----+----+----.----.----.----.----.----+----+----.----+
+    // 0x110 |                          ...  00   00  ...                          |   crc   | 0x150
+    //       +----.----.----.----.----.----.----.----.----.----.----.----.----.----+----.----+
+    // 0x120 |                ...  00  ...                  05                     |   crc   | 0x160
+    //       +----.----.----.----.----.----.----.----.----.----.----.----.----.----+----.----+
+    //
+    // WIP. Read in all speculative passes into array
+    // 4 separate fields? active vs inactive sector for 2 passes?
+    // something else entirely?
+
+    Pass* passes = malloc(sizeof(Pass) * CHARLIE_N_PASSES);
+
+    for(size_t i = 0; i < CHARLIE_N_PASSES; i++) {
+        passes[i] = pass_parse(data, 4 + (i / 2), 0, (i % 2) * 7);
+    }
+
+    return passes;
+}
+
+static Transaction* transactions_parse(const MfClassicData* data) {
+    // Transaction history (Sectors 6–7)
+    //
+    //       0    1    2    3    4    5    6    7    8    9    A    B    C    D    E    F
+    //       +----.----.----.----.----.----.----+----.----.----.----.----.----.----+----.----+
+    // 0x180 |           transaction0           |           transaction1           |   crc   |
+    //       +----.----.----.----.----.----.----+----.----.----.----.----.----.----+----.----+
+    //  ...                   ...                                ...                   ...
+    //       +----.----.----.----.----.----.----+----.----.----.----.----.----.----+----.----+
+    // 0x1D0 |           transaction8           |           transaction9           |   crc   |
+    //       +----.----.----.----.----.----.----+----.----.----.----.----.----.----+----.----+
+    // 0x1E0 |                          ...  00   00  ...                          |   crc   |
+    //       +----.----.----.----.----.----.----.----.----.----.----.----.----.----+----.----+
+    //
+    // Transactions are not sorted, rather, appear to get overwritten
+    // sequentially. (eg, sorted modulo array rotation)
+
+    Transaction* transactions = malloc(sizeof(Transaction) * CHARLIE_N_TRANSACTION_HISTORY);
+
+    // Parse each transaction field using some modular math magic to get the offsets:
+    // move from sector 6 -> 7 after the first 6 transactions
+    // move a block within a given sector every 2 transactions, reset every 3 blocks (as sector has changed)
+    // alternate between a start byte of 0 and 7 with every iteration
+    for(size_t i = 0; i < CHARLIE_N_TRANSACTION_HISTORY; i++) {
+        transactions[i] = transaction_parse(data, 6 + (i / 6), (i / 2) % 3, (i % 2) * 7);
+    }
+
+    // Iterate through the array to find the maximum (newest) date value
+    int max_idx = 0;
+    for(int i = 1; i < CHARLIE_N_TRANSACTION_HISTORY; i++) {
+        if(dt_ge(transactions[i].date, transactions[max_idx].date)) {
+            max_idx = i;
+        }
+    }
+
+    // Sort by rotating
+    for(int r = 0; r < (max_idx + 1); r++) {
+        // Store the first element
+        Transaction temp = transactions[0];
+        // Shift elements to the left
+        for(int i = 0; i < CHARLIE_N_TRANSACTION_HISTORY - 1; i++) {
+            transactions[i] = transactions[i + 1];
+        }
+        // Move the first element to the last
+        transactions[CHARLIE_N_TRANSACTION_HISTORY - 1] = temp;
+    }
+
+    // Reverse order, such that newest is first, oldest last
+    for(int i = 0; i < CHARLIE_N_TRANSACTION_HISTORY / 2; i++) {
+        // Swap elements at index i and size - i - 1
+        Transaction temp = transactions[i];
+        transactions[i] = transactions[CHARLIE_N_TRANSACTION_HISTORY - i - 1];
+        transactions[CHARLIE_N_TRANSACTION_HISTORY - i - 1] = temp;
+    }
+
+    return transactions;
+}
+
+/*
+static DateTime expiry(DateTime iss) {
+    // Per Metrodroid CharlieCard parser (https://github.com/metrodroid/metrodroid/blob/master/src/commonMain/kotlin/au/id/micolous/metrodroid/transit/charlie/CharlieCardTransitData.kt)
+    // Expiry not explicitly stored in card data; rather, calculated from date of issue
+    // Cards were first issued in 2006, expired in 5 years, w/ no printed expiry date
+    // Cards issued after 2011 expire in 10 years
+    //
+    // Per DEFCON31 researcher's work (cited above):
+    // Student cards last one school year and expire at the end of August the following year
+    // Pre-2011 issued cards expire in 7 years, not 5 as claimed by Metrodroid
+    // Post-2011 expire in 10 years, less one day
+    // Redundant function given the existance of the end validity field?
+    // Any important distinctions between the two?
+    
+
+    // perhaps additionally clipping to 2030-12-__ in anticipation of upcoming system migration?
+    // need to get a new card to confirm.
+
+    // TODO add card type logic for student card expiry
+    DateTime exp;
+    if(iss.year < 2011) {
+        // add 7 years; assumes average year of 8766 hrs (to account for leap years)
+        // may be off by a few hours as a result
+        exp = dt_delta(iss, 7 * 8766 * 60 * 60);
+    } else {
+        // add 10 years, subtract a day. Same assumption as above
+        exp = dt_delta(iss, ((10 * 8766) - 24) * 60 * 60);
+    }
+
+    return exp;
+}
+
+static bool expired(DateTime expiry, DateTime last_transaction) {
+    // if a card has sat unused for >2 years, expired (verify this claim?)
+    // else expired if current date > expiry date
+
+    uint32_t ts_exp = datetime_datetime_to_timestamp(&expiry);
+    uint32_t ts_last = datetime_datetime_to_timestamp(&last_transaction);
+    uint32_t ts_now = time_now();
+
+    return (ts_exp <= ts_now) | ((ts_now - ts_last) >= (2 * 365 * 24 * 60 * 60));
+}
+*/
+
+// **********************************************************
+// ****************** STRING FORMATTING *********************
+// **********************************************************
+
+void locale_format_dt_cat(FuriString* out, const DateTime* dt) {
+    // helper to print datetimes
+    FuriString* s = furi_string_alloc();
+
+    LocaleDateFormat date_format = locale_get_date_format();
+    const char* separator = (date_format == LocaleDateFormatDMY) ? "." : "/";
+    locale_format_date(s, dt, date_format, separator);
+    furi_string_cat(out, s);
+    locale_format_time(s, dt, locale_get_time_format(), false);
+    furi_string_cat_printf(out, "  ");
+    furi_string_cat(out, s);
+
+    furi_string_free(s);
+}
+
+void type_format_cat(FuriString* out, uint16_t type) {
+    const char* s;
+    if(!get_map_item(type, charliecard_types, kNumTypes, &s)) {
+        s = "";
+        furi_string_cat_printf(out, "Unknown-%u", type);
+    }
+
+    furi_string_cat_str(out, s);
+}
+
+void pass_format_cat(FuriString* out, Pass pass) {
+    furi_string_cat_printf(out, "\n-Pre: %b", pass.pre);
+    // type_format_cat(out, pass.type);
+    furi_string_cat_printf(out, "\n-Post: ");
+    type_format_cat(out, pass.post);
+    // locale_format_dt_cat(out, &pass.start);
+    furi_string_cat_printf(out, "\n-Date: ");
+    locale_format_dt_cat(out, &pass.date);
+}
+
+void passes_format_cat(FuriString* out, Pass* passes) {
+    // only print passes if DEBUG on
+    if(!is_debug()) {
+        return;
+    }
+
+    // only print if there is at least 1 valid pass to print
+    bool any_valid = false;
+    for(size_t i = 0; i < CHARLIE_N_PASSES; i++) {
+        any_valid |= passes[i].valid;
+    }
+    if(!any_valid) {
+        return;
+    }
+
+    furi_string_cat_printf(out, "\nPasses (DEBUG / WIP):");
+    for(size_t i = 0; i < CHARLIE_N_PASSES; i++) {
+        if(passes[i].valid) {
+            furi_string_cat_printf(out, "\nPass %u", i + 1);
+            pass_format_cat(out, passes[i]);
+            furi_string_cat_printf(out, "\n");
+        }
+    }
+}
+
+void money_format_cat(FuriString* out, Money money) {
+    furi_string_cat_printf(out, "$%u.%02u", money.dollars, money.cents);
+}
+
+void transaction_format_cat(FuriString* out, Transaction transaction) {
+    const char* sep = "   ";
+    const char* sta;
+
+    locale_format_dt_cat(out, &transaction.date);
+    furi_string_cat_printf(out, "\n%s", !!(transaction.g_flag & 0x1) ? "-" : "+");
+    money_format_cat(out, transaction.fare);
+    if(!!(transaction.g_flag & 0x1) && (transaction.fare.dollars == FARE_BUS.dollars) &&
+       (transaction.fare.cents == FARE_BUS.cents)) {
+        // if not a refill, and the fare amount is equal to bus fare (any better approach? flag bits for modality?)
+        // format for bus — supposedly some correlation between gate ID & bus #, haven't investigated
+        furi_string_cat_printf(out, "%s#%u", sep, transaction.gate);
+    } else if(get_map_item(transaction.gate, charliecard_fare_gate_ids, kNumFareGateIds, &sta)) {
+        // station found in fare gate ID map, append station name
+        furi_string_cat_str(out, sep);
+        furi_string_cat_str(out, sta);
+    } else {
+        // no found station in fare gate ID map & not a bus, just print ID w/o add'l info
+        furi_string_cat_printf(out, "%s#%u", sep, transaction.gate);
+    }
+    // print flags for debugging purposes
+    if(is_debug()) {
+        furi_string_cat_printf(out, "%s%x%s%x", sep, transaction.g_flag, sep, transaction.f_flag);
+    }
+}
+
+void transactions_format_cat(FuriString* out, Transaction* transactions) {
+    furi_string_cat_printf(out, "\nTransactions:");
+    for(size_t i = 0; i < CHARLIE_N_TRANSACTION_HISTORY; i++) {
+        furi_string_cat_printf(out, "\n");
+        transaction_format_cat(out, transactions[i]);
+        furi_string_cat_printf(out, "\n");
+    }
+}
+
+// **********************************************************
+// **************** NFC PLUGIN BOILERPLATE ******************
+// **********************************************************
+
+static bool charliecard_parse(FuriString* parsed_data, const MfClassicData* data) {
+    bool parsed = false;
+
+    do {
+        // Verify key
+        // arbitrary sector in the main data portion
+        const uint8_t verify_sector = 3;
+        const MfClassicSectorTrailer* sec_tr =
+            mf_classic_get_sector_trailer_by_sector(data, verify_sector);
+
+        const uint64_t key_a =
+            bit_lib_bytes_to_num_be(sec_tr->key_a.data, COUNT_OF(sec_tr->key_a.data));
+
+        if(key_a != charliecard_1k_keys[verify_sector].a) break;
+
+        // parse card data
+        const uint32_t card_number = mfg_sector_parse(data);
+        const CounterSector counter_sector = counter_sector_parse(data);
+        const BalanceSector balance_sector =
+            balance_sector_parse(data, counter_sector.active_balance_sector);
+        Pass* passes = passes_parse(data);
+        Transaction* transactions = transactions_parse(data);
+
+        // print/append card data
+        furi_string_cat_printf(parsed_data, "\e#CharlieCard");
+        furi_string_cat_printf(parsed_data, "\nSerial: 5-%lu", card_number);
+
+        // Type and balance 0 on some (Perq) cards
+        // (ie no "main" type / balance / end validity,
+        //  essentially only pass & trip info)
+        // skip/change formatting for that case?
+        furi_string_cat_printf(parsed_data, "\nBal: ");
+        money_format_cat(parsed_data, balance_sector.balance);
+
+        furi_string_cat_printf(parsed_data, "\nType: ");
+        type_format_cat(parsed_data, balance_sector.type);
+
+        furi_string_cat_printf(parsed_data, "\nTrip Count: %u", counter_sector.n_uses);
+
+        furi_string_cat_printf(parsed_data, "\nIssued: ");
+        locale_format_dt_cat(parsed_data, &balance_sector.issued);
+
+        if(!dt_eq(balance_sector.end_validity, CHARLIE_EPOCH) &
+           dt_ge(balance_sector.end_validity, balance_sector.issued)) {
+            // sometimes (seen on Perq cards) end validity field is all 0
+            // When this is the case, calc'd end validity is equal to CHARLIE_EPOCH).
+            // Only print if not 0, & end validity after issuance date
+            furi_string_cat_printf(parsed_data, "\nExpiry: ");
+            locale_format_dt_cat(parsed_data, &balance_sector.end_validity);
+        }
+
+        // const DateTime last = date_parse(data, active_sector, 0, 1);
+        // furi_string_cat_printf(parsed_data, "\nExpired: %s", expired(e_v, last) ? "Yes" : "No");
+
+        transactions_format_cat(parsed_data, transactions);
+        free(transactions);
+
+        passes_format_cat(parsed_data, passes);
+        free(passes);
+
+        parsed = true;
+    } while(false);
+
+    return parsed;
+}
+
+static NfcCommand
+    metroflip_scene_charlicard_poller_callback(NfcGenericEvent event, void* context) {
+    furi_assert(context);
+    furi_assert(event.event_data);
+    furi_assert(event.protocol == NfcProtocolMfClassic);
+
+    NfcCommand command = NfcCommandContinue;
+    const MfClassicPollerEvent* mfc_event = event.event_data;
+    Metroflip* app = context;
+
+    if(mfc_event->type == MfClassicPollerEventTypeCardDetected) {
+        view_dispatcher_send_custom_event(app->view_dispatcher, MetroflipCustomEventCardDetected);
+        command = NfcCommandContinue;
+    } else if(mfc_event->type == MfClassicPollerEventTypeCardLost) {
+        view_dispatcher_send_custom_event(app->view_dispatcher, MetroflipCustomEventCardLost);
+        app->sec_num = 0;
+        command = NfcCommandStop;
+    } else if(mfc_event->type == MfClassicPollerEventTypeRequestMode) {
+        mfc_event->data->poller_mode.mode = MfClassicPollerModeRead;
+
+    } else if(mfc_event->type == MfClassicPollerEventTypeRequestReadSector) {
+        MfClassicKey key = {0};
+        bit_lib_num_to_bytes_be(charliecard_1k_keys[app->sec_num].a, COUNT_OF(key.data), key.data);
+
+        MfClassicKeyType key_type = MfClassicKeyTypeA;
+        mfc_event->data->read_sector_request_data.sector_num = app->sec_num;
+        mfc_event->data->read_sector_request_data.key = key;
+        mfc_event->data->read_sector_request_data.key_type = key_type;
+        mfc_event->data->read_sector_request_data.key_provided = true;
+        if(app->sec_num == 16) {
+            mfc_event->data->read_sector_request_data.key_provided = false;
+            app->sec_num = 0;
+        }
+        app->sec_num++;
+    } else if(mfc_event->type == MfClassicPollerEventTypeSuccess) {
+        nfc_device_set_data(
+            app->nfc_device, NfcProtocolMfClassic, nfc_poller_get_data(app->poller));
+        const MfClassicData* mfc_data = nfc_device_get_data(app->nfc_device, NfcProtocolMfClassic);
+        FuriString* parsed_data = furi_string_alloc();
+        Widget* widget = app->widget;
+
+        dolphin_deed(DolphinDeedNfcReadSuccess);
+        furi_string_reset(app->text_box_store);
+        if(!charliecard_parse(parsed_data, mfc_data)) {
+            furi_string_reset(app->text_box_store);
+            FURI_LOG_I(TAG, "Unknown card type");
+            furi_string_printf(parsed_data, "\e#Unknown card\n");
+        }
+        widget_add_text_scroll_element(widget, 0, 0, 128, 64, furi_string_get_cstr(parsed_data));
+
+        widget_add_button_element(
+            widget, GuiButtonTypeRight, "Exit", metroflip_exit_widget_callback, app);
+
+        furi_string_free(parsed_data);
+        view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewWidget);
+        command = NfcCommandStop;
+        metroflip_app_blink_stop(app);
+    } else if(mfc_event->type == MfClassicPollerEventTypeFail) {
+        FURI_LOG_I(TAG, "fail");
+        command = NfcCommandContinue;
+    }
+
+    return command;
+}
+
+static void charliecard_on_enter(Metroflip* app) {
+    dolphin_deed(DolphinDeedNfcRead);
+
+    app->sec_num = 0;
+
+    // Setup view
+    Popup* popup = app->popup;
+    popup_set_header(popup, "Apply\n card to\nthe back", 68, 30, AlignLeft, AlignTop);
+    popup_set_icon(popup, 0, 3, &I_RFIDDolphinReceive_97x61);
+
+    // Start worker
+    view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewPopup);
+    app->poller = nfc_poller_alloc(app->nfc, NfcProtocolMfClassic);
+    nfc_poller_start(app->poller, metroflip_scene_charlicard_poller_callback, app);
+
+    metroflip_app_blink_start(app);
+}
+
+static bool charliecard_on_event(Metroflip* app, SceneManagerEvent event) {
+    bool consumed = false;
+
+    if(event.type == SceneManagerEventTypeCustom) {
+        if(event.event == MetroflipCustomEventCardDetected) {
+            Popup* popup = app->popup;
+            popup_set_header(popup, "DON'T\nMOVE", 68, 30, AlignLeft, AlignTop);
+            consumed = true;
+        } else if(event.event == MetroflipCustomEventCardLost) {
+            Popup* popup = app->popup;
+            popup_set_header(popup, "Card \n lost", 68, 30, AlignLeft, AlignTop);
+            consumed = true;
+        } else if(event.event == MetroflipCustomEventWrongCard) {
+            Popup* popup = app->popup;
+            popup_set_header(popup, "WRONG \n CARD", 68, 30, AlignLeft, AlignTop);
+            consumed = true;
+        } else if(event.event == MetroflipCustomEventPollerFail) {
+            Popup* popup = app->popup;
+            popup_set_header(popup, "Failed", 68, 30, AlignLeft, AlignTop);
+            consumed = true;
+        }
+    } else if(event.type == SceneManagerEventTypeBack) {
+        scene_manager_search_and_switch_to_previous_scene(app->scene_manager, MetroflipSceneStart);
+        consumed = true;
+    }
+
+    return consumed;
+}
+
+static void charliecard_on_exit(Metroflip* app) {
+    widget_reset(app->widget);
+
+    if(app->poller) {
+        nfc_poller_stop(app->poller);
+        nfc_poller_free(app->poller);
+    }
+
+    // Clear view
+    popup_reset(app->popup);
+
+    metroflip_app_blink_stop(app);
+}
+
+/* Actual implementation of app<>plugin interface */
+static const MetroflipPlugin charliecard_plugin = {
+    .card_name = "Charliecard",
+    .plugin_on_enter = charliecard_on_enter,
+    .plugin_on_event = charliecard_on_event,
+    .plugin_on_exit = charliecard_on_exit,
+
+};
+
+/* Plugin descriptor to comply with basic plugin specification */
+static const FlipperAppPluginDescriptor charliecard_plugin_descriptor = {
+    .appid = METROFLIP_SUPPORTED_CARD_PLUGIN_APP_ID,
+    .ep_api_version = METROFLIP_SUPPORTED_CARD_PLUGIN_API_VERSION,
+    .entry_point = &charliecard_plugin,
+};
+
+/* Plugin entry point - must return a pointer to const descriptor  */
+const FlipperAppPluginDescriptor* charliecard_plugin_ep(void) {
+    return &charliecard_plugin_descriptor;
+}

+ 29 - 9
scenes/metroflip_scene_clipper.c → scenes/plugins/clipper.c

@@ -19,7 +19,7 @@
  * along with this program.  If not, see <http://www.gnu.org/licenses/>.
  */
 #include <flipper_application.h>
-#include "../metroflip_i.h"
+#include "../../metroflip_i.h"
 #include <nfc/protocols/mf_desfire/mf_desfire_poller.h>
 
 #include <lib/nfc/protocols/mf_desfire/mf_desfire.h>
@@ -28,6 +28,8 @@
 #include <datetime.h>
 #include <locale/locale.h>
 #include <inttypes.h>
+#include "../../api/metroflip/metroflip_api.h"
+#include "../../metroflip_plugins.h"
 
 #define TAG "Metroflip:Scene:Clipper"
 
@@ -564,7 +566,7 @@ static void furi_string_cat_timestamp(
     furi_string_free(time_str);
 }
 
-static NfcCommand metroflip_scene_clipper_poller_callback(NfcGenericEvent event, void* context) {
+static NfcCommand clipper_poller_callback(NfcGenericEvent event, void* context) {
     furi_assert(event.protocol == NfcProtocolMfDesfire);
 
     Metroflip* app = context;
@@ -599,8 +601,7 @@ static NfcCommand metroflip_scene_clipper_poller_callback(NfcGenericEvent event,
     return command;
 }
 
-void metroflip_scene_clipper_on_enter(void* context) {
-    Metroflip* app = context;
+static void clipper_on_enter(Metroflip* app) {
     dolphin_deed(DolphinDeedNfcRead);
 
     // Setup view
@@ -612,13 +613,12 @@ void metroflip_scene_clipper_on_enter(void* context) {
     view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewPopup);
     nfc_scanner_alloc(app->nfc);
     app->poller = nfc_poller_alloc(app->nfc, NfcProtocolMfDesfire);
-    nfc_poller_start(app->poller, metroflip_scene_clipper_poller_callback, app);
+    nfc_poller_start(app->poller, clipper_poller_callback, app);
 
     metroflip_app_blink_start(app);
 }
 
-bool metroflip_scene_clipper_on_event(void* context, SceneManagerEvent event) {
-    Metroflip* app = context;
+static bool clipper_on_event(Metroflip* app, SceneManagerEvent event) {
     bool consumed = false;
 
     if(event.type == SceneManagerEventTypeCustom) {
@@ -647,8 +647,7 @@ bool metroflip_scene_clipper_on_event(void* context, SceneManagerEvent event) {
     return consumed;
 }
 
-void metroflip_scene_clipper_on_exit(void* context) {
-    Metroflip* app = context;
+static void clipper_on_exit(Metroflip* app) {
     widget_reset(app->widget);
     metroflip_app_blink_stop(app);
 
@@ -657,3 +656,24 @@ void metroflip_scene_clipper_on_exit(void* context) {
         nfc_poller_free(app->poller);
     }
 }
+
+/* Actual implementation of app<>plugin interface */
+static const MetroflipPlugin clipper_plugin = {
+    .card_name = "Clipper",
+    .plugin_on_enter = clipper_on_enter,
+    .plugin_on_event = clipper_on_event,
+    .plugin_on_exit = clipper_on_exit,
+
+};
+
+/* Plugin descriptor to comply with basic plugin specification */
+static const FlipperAppPluginDescriptor clipper_plugin_descriptor = {
+    .appid = METROFLIP_SUPPORTED_CARD_PLUGIN_APP_ID,
+    .ep_api_version = METROFLIP_SUPPORTED_CARD_PLUGIN_API_VERSION,
+    .entry_point = &clipper_plugin,
+};
+
+/* Plugin entry point - must return a pointer to const descriptor  */
+const FlipperAppPluginDescriptor* clipper_plugin_ep(void) {
+    return &clipper_plugin_descriptor;
+}

+ 29 - 9
scenes/metroflip_scene_itso.c → scenes/plugins/itso.c

@@ -1,10 +1,12 @@
 /* itso.c - Parser for ITSO cards (United Kingdom). */
-#include "../metroflip_i.h"
+#include "../../metroflip_i.h"
 #include <flipper_application.h>
 
 #include <lib/nfc/protocols/mf_desfire/mf_desfire.h>
 #include <lib/nfc/protocols/mf_desfire/mf_desfire_poller.h>
 #include <lib/toolbox/strint.h>
+#include "../../api/metroflip/metroflip_api.h"
+#include "../../metroflip_plugins.h"
 
 #include <applications/services/locale/locale.h>
 #include <datetime.h>
@@ -111,7 +113,7 @@ bool itso_parse(const NfcDevice* device, FuriString* parsed_data) {
     return parsed;
 }
 
-static NfcCommand metroflip_scene_itso_poller_callback(NfcGenericEvent event, void* context) {
+static NfcCommand itso_poller_callback(NfcGenericEvent event, void* context) {
     furi_assert(event.protocol == NfcProtocolMfDesfire);
 
     Metroflip* app = context;
@@ -146,8 +148,7 @@ static NfcCommand metroflip_scene_itso_poller_callback(NfcGenericEvent event, vo
     return command;
 }
 
-void metroflip_scene_itso_on_enter(void* context) {
-    Metroflip* app = context;
+static void itso_on_enter(Metroflip* app) {
     dolphin_deed(DolphinDeedNfcRead);
 
     // Setup view
@@ -159,13 +160,12 @@ void metroflip_scene_itso_on_enter(void* context) {
     view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewPopup);
     nfc_scanner_alloc(app->nfc);
     app->poller = nfc_poller_alloc(app->nfc, NfcProtocolMfDesfire);
-    nfc_poller_start(app->poller, metroflip_scene_itso_poller_callback, app);
+    nfc_poller_start(app->poller, itso_poller_callback, app);
 
     metroflip_app_blink_start(app);
 }
 
-bool metroflip_scene_itso_on_event(void* context, SceneManagerEvent event) {
-    Metroflip* app = context;
+static bool itso_on_event(Metroflip* app, SceneManagerEvent event) {
     bool consumed = false;
 
     if(event.type == SceneManagerEventTypeCustom) {
@@ -194,8 +194,7 @@ bool metroflip_scene_itso_on_event(void* context, SceneManagerEvent event) {
     return consumed;
 }
 
-void metroflip_scene_itso_on_exit(void* context) {
-    Metroflip* app = context;
+static void itso_on_exit(Metroflip* app) {
     widget_reset(app->widget);
     metroflip_app_blink_stop(app);
     if(app->poller) {
@@ -203,3 +202,24 @@ void metroflip_scene_itso_on_exit(void* context) {
         nfc_poller_free(app->poller);
     }
 }
+
+/* Actual implementation of app<>plugin interface */
+static const MetroflipPlugin itso_plugin = {
+    .card_name = "ITSO",
+    .plugin_on_enter = itso_on_enter,
+    .plugin_on_event = itso_on_event,
+    .plugin_on_exit = itso_on_exit,
+
+};
+
+/* Plugin descriptor to comply with basic plugin specification */
+static const FlipperAppPluginDescriptor itso_plugin_descriptor = {
+    .appid = METROFLIP_SUPPORTED_CARD_PLUGIN_APP_ID,
+    .ep_api_version = METROFLIP_SUPPORTED_CARD_PLUGIN_API_VERSION,
+    .entry_point = &itso_plugin,
+};
+
+/* Plugin entry point - must return a pointer to const descriptor  */
+const FlipperAppPluginDescriptor* itso_plugin_ep(void) {
+    return &itso_plugin_descriptor;
+}

+ 68 - 23
scenes/metroflip_scene_metromoney.c → scenes/plugins/metromoney.c

@@ -1,6 +1,6 @@
 
 #include <flipper_application.h>
-#include "../metroflip_i.h"
+#include "../../metroflip_i.h"
 
 #include <nfc/protocols/mf_classic/mf_classic_poller_sync.h>
 #include <nfc/protocols/mf_classic/mf_classic.h>
@@ -12,12 +12,31 @@
 #include <nfc/nfc.h>
 #include <nfc/nfc_device.h>
 #include <nfc/nfc_listener.h>
+#include "../../api/metroflip/metroflip_api.h"
+#include "../../metroflip_plugins.h"
 
 #define TAG "Metroflip:Scene:Metromoney"
 
-static bool metromoney_parse(const NfcDevice* device, const MfClassicData* data, Metroflip* app) {
-    furi_assert(device);
-
+const MfClassicKeyPair metromoney_1k_keys[16] = {
+    {.a = 0x2803BCB0C7E1, .b = 0x4FA9EB49F75E},
+    {.a = 0x9C616585E26D, .b = 0xD1C71E590D16},
+    {.a = 0x9C616585E26D, .b = 0xA160FCD5EC4C},
+    {.a = 0x9C616585E26D, .b = 0xA160FCD5EC4C},
+    {.a = 0x9C616585E26D, .b = 0xA160FCD5EC4C},
+    {.a = 0x9C616585E26D, .b = 0xA160FCD5EC4C},
+    {.a = 0xFFFFFFFFFFFF, .b = 0xFFFFFFFFFFFF},
+    {.a = 0xFFFFFFFFFFFF, .b = 0xFFFFFFFFFFFF},
+    {.a = 0x112233445566, .b = 0x361A62F35BC9},
+    {.a = 0x112233445566, .b = 0x361A62F35BC9},
+    {.a = 0xFFFFFFFFFFFF, .b = 0xFFFFFFFFFFFF},
+    {.a = 0xFFFFFFFFFFFF, .b = 0xFFFFFFFFFFFF},
+    {.a = 0xFFFFFFFFFFFF, .b = 0xFFFFFFFFFFFF},
+    {.a = 0xFFFFFFFFFFFF, .b = 0xFFFFFFFFFFFF},
+    {.a = 0xFFFFFFFFFFFF, .b = 0xFFFFFFFFFFFF},
+    {.a = 0xFFFFFFFFFFFF, .b = 0xFFFFFFFFFFFF},
+};
+
+static bool metromoney_parse(FuriString* parsed_data, const MfClassicData* data) {
     bool parsed = false;
 
     do {
@@ -31,7 +50,7 @@ static bool metromoney_parse(const NfcDevice* device, const MfClassicData* data,
         const uint64_t key =
             bit_lib_bytes_to_num_be(sec_tr->key_a.data, COUNT_OF(sec_tr->key_a.data));
         if(key != metromoney_1k_keys[ticket_sector_number].a) break;
-        FURI_LOG_I(TAG, "passed key check");
+        FURI_LOG_D(TAG, "passed key check");
         // Parse data
         const uint8_t start_block_num =
             mf_classic_get_first_block_num_of_sector(ticket_sector_number);
@@ -47,18 +66,19 @@ static bool metromoney_parse(const NfcDevice* device, const MfClassicData* data,
         size_t uid_len = 0;
         const uint8_t* uid = mf_classic_get_uid(data, &uid_len);
         uint32_t card_number = bit_lib_bytes_to_num_le(uid, 4);
-        strncpy(app->card_type, "Metromoney", sizeof(app->card_type));
-        app->balance_lari = balance_lari;
-        app->balance_tetri = balance_tetri;
-        app->card_number = card_number;
+        furi_string_printf(
+            parsed_data,
+            "\e#Metromoney\nCard number: %lu\nBalance: %lu.%02u GEL",
+            card_number,
+            balance_lari,
+            balance_tetri);
         parsed = true;
     } while(false);
 
     return parsed;
 }
 
-static NfcCommand
-    metroflip_scene_metromoney_poller_callback(NfcGenericEvent event, void* context) {
+static NfcCommand metromoney_poller_callback(NfcGenericEvent event, void* context) {
     furi_assert(context);
     furi_assert(event.event_data);
     furi_assert(event.protocol == NfcProtocolMfClassic);
@@ -95,8 +115,18 @@ static NfcCommand
         nfc_device_set_data(
             app->nfc_device, NfcProtocolMfClassic, nfc_poller_get_data(app->poller));
         const MfClassicData* mfc_data = nfc_device_get_data(app->nfc_device, NfcProtocolMfClassic);
-        metromoney_parse(app->nfc_device, mfc_data, app);
-        view_dispatcher_send_custom_event(app->view_dispatcher, MetroflipCustomEventPollerSuccess);
+        FuriString* parsed_data = furi_string_alloc();
+        Widget* widget = app->widget;
+        dolphin_deed(DolphinDeedNfcReadSuccess);
+        furi_string_reset(app->text_box_store);
+        metromoney_parse(parsed_data, mfc_data);
+        widget_add_text_scroll_element(widget, 0, 0, 128, 64, furi_string_get_cstr(parsed_data));
+
+        widget_add_button_element(
+            widget, GuiButtonTypeRight, "Exit", metroflip_exit_widget_callback, app);
+
+        furi_string_free(parsed_data);
+        view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewWidget);
         command = NfcCommandStop;
         metroflip_app_blink_stop(app);
     } else if(mfc_event->type == MfClassicPollerEventTypeFail) {
@@ -107,8 +137,7 @@ static NfcCommand
     return command;
 }
 
-void metroflip_scene_metromoney_on_enter(void* context) {
-    Metroflip* app = context;
+static void metromoney_on_enter(Metroflip* app) {
     dolphin_deed(DolphinDeedNfcRead);
 
     app->sec_num = 0;
@@ -122,13 +151,12 @@ void metroflip_scene_metromoney_on_enter(void* context) {
     view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewPopup);
     nfc_scanner_alloc(app->nfc);
     app->poller = nfc_poller_alloc(app->nfc, NfcProtocolMfClassic);
-    nfc_poller_start(app->poller, metroflip_scene_metromoney_poller_callback, app);
+    nfc_poller_start(app->poller, metromoney_poller_callback, app);
 
     metroflip_app_blink_start(app);
 }
 
-bool metroflip_scene_metromoney_on_event(void* context, SceneManagerEvent event) {
-    Metroflip* app = context;
+static bool metromoney_on_event(Metroflip* app, SceneManagerEvent event) {
     bool consumed = false;
 
     if(event.type == SceneManagerEventTypeCustom) {
@@ -148,9 +176,6 @@ bool metroflip_scene_metromoney_on_event(void* context, SceneManagerEvent event)
             Popup* popup = app->popup;
             popup_set_header(popup, "Failed", 68, 30, AlignLeft, AlignTop);
             consumed = true;
-        } else if(event.event == MetroflipCustomEventPollerSuccess) {
-            scene_manager_next_scene(app->scene_manager, MetroflipSceneReadSuccess);
-            consumed = true;
         }
     } else if(event.type == SceneManagerEventTypeBack) {
         scene_manager_search_and_switch_to_previous_scene(app->scene_manager, MetroflipSceneStart);
@@ -160,8 +185,7 @@ bool metroflip_scene_metromoney_on_event(void* context, SceneManagerEvent event)
     return consumed;
 }
 
-void metroflip_scene_metromoney_on_exit(void* context) {
-    Metroflip* app = context;
+static void metromoney_on_exit(Metroflip* app) {
     widget_reset(app->widget);
 
     if(app->poller) {
@@ -174,3 +198,24 @@ void metroflip_scene_metromoney_on_exit(void* context) {
 
     metroflip_app_blink_stop(app);
 }
+
+/* Actual implementation of app<>plugin interface */
+static const MetroflipPlugin metromoney_plugin = {
+    .card_name = "Metromoney",
+    .plugin_on_enter = metromoney_on_enter,
+    .plugin_on_event = metromoney_on_event,
+    .plugin_on_exit = metromoney_on_exit,
+
+};
+
+/* Plugin descriptor to comply with basic plugin specification */
+static const FlipperAppPluginDescriptor metromoney_plugin_descriptor = {
+    .appid = METROFLIP_SUPPORTED_CARD_PLUGIN_APP_ID,
+    .ep_api_version = METROFLIP_SUPPORTED_CARD_PLUGIN_API_VERSION,
+    .entry_point = &metromoney_plugin,
+};
+
+/* Plugin entry point - must return a pointer to const descriptor  */
+const FlipperAppPluginDescriptor* metromoney_plugin_ep(void) {
+    return &metromoney_plugin_descriptor;
+}

+ 29 - 9
scenes/metroflip_scene_myki.c → scenes/plugins/myki.c

@@ -3,8 +3,10 @@
 #include <lib/nfc/protocols/mf_desfire/mf_desfire.h>
 #include <stdio.h>
 
-#include "../metroflip_i.h"
+#include "../../metroflip_i.h"
 #include <nfc/protocols/mf_desfire/mf_desfire_poller.h>
+#include "../../api/metroflip/metroflip_api.h"
+#include "../../metroflip_plugins.h"
 
 #define TAG "Metroflip:Scene:myki"
 
@@ -94,7 +96,7 @@ bool myki_parse(const NfcDevice* device, FuriString* parsed_data) {
     return parsed;
 }
 
-static NfcCommand metroflip_scene_myki_poller_callback(NfcGenericEvent event, void* context) {
+static NfcCommand myki_poller_callback(NfcGenericEvent event, void* context) {
     furi_assert(event.protocol == NfcProtocolMfDesfire);
 
     Metroflip* app = context;
@@ -129,8 +131,7 @@ static NfcCommand metroflip_scene_myki_poller_callback(NfcGenericEvent event, vo
     return command;
 }
 
-void metroflip_scene_myki_on_enter(void* context) {
-    Metroflip* app = context;
+static void myki_on_enter(Metroflip* app) {
     dolphin_deed(DolphinDeedNfcRead);
 
     // Setup view
@@ -142,13 +143,12 @@ void metroflip_scene_myki_on_enter(void* context) {
     view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewPopup);
     nfc_scanner_alloc(app->nfc);
     app->poller = nfc_poller_alloc(app->nfc, NfcProtocolMfDesfire);
-    nfc_poller_start(app->poller, metroflip_scene_myki_poller_callback, app);
+    nfc_poller_start(app->poller, myki_poller_callback, app);
 
     metroflip_app_blink_start(app);
 }
 
-bool metroflip_scene_myki_on_event(void* context, SceneManagerEvent event) {
-    Metroflip* app = context;
+static bool myki_on_event(Metroflip* app, SceneManagerEvent event) {
     bool consumed = false;
 
     if(event.type == SceneManagerEventTypeCustom) {
@@ -177,8 +177,7 @@ bool metroflip_scene_myki_on_event(void* context, SceneManagerEvent event) {
     return consumed;
 }
 
-void metroflip_scene_myki_on_exit(void* context) {
-    Metroflip* app = context;
+static void myki_on_exit(Metroflip* app) {
     widget_reset(app->widget);
     metroflip_app_blink_stop(app);
     if(app->poller) {
@@ -186,3 +185,24 @@ void metroflip_scene_myki_on_exit(void* context) {
         nfc_poller_free(app->poller);
     }
 }
+
+/* Actual implementation of app<>plugin interface */
+static const MetroflipPlugin myki_plugin = {
+    .card_name = "Myki",
+    .plugin_on_enter = myki_on_enter,
+    .plugin_on_event = myki_on_event,
+    .plugin_on_exit = myki_on_exit,
+
+};
+
+/* Plugin descriptor to comply with basic plugin specification */
+static const FlipperAppPluginDescriptor myki_plugin_descriptor = {
+    .appid = METROFLIP_SUPPORTED_CARD_PLUGIN_APP_ID,
+    .ep_api_version = METROFLIP_SUPPORTED_CARD_PLUGIN_API_VERSION,
+    .entry_point = &myki_plugin,
+};
+
+/* Plugin entry point - must return a pointer to const descriptor  */
+const FlipperAppPluginDescriptor* myki_plugin_ep(void) {
+    return &myki_plugin_descriptor;
+}

+ 29 - 9
scenes/metroflip_scene_opal.c → scenes/plugins/opal.c

@@ -28,11 +28,13 @@
  * along with this program.  If not, see <http://www.gnu.org/licenses/>.
  */
 
-#include "../metroflip_i.h"
+#include "../../metroflip_i.h"
 #include <flipper_application.h>
 
 #include <lib/nfc/protocols/mf_desfire/mf_desfire.h>
 #include <lib/nfc/protocols/mf_desfire/mf_desfire_poller.h>
+#include "../../api/metroflip/metroflip_api.h"
+#include "../../metroflip_plugins.h"
 
 #include <applications/services/locale/locale.h>
 #include <datetime.h>
@@ -215,7 +217,7 @@ bool opal_parse(const NfcDevice* device, FuriString* parsed_data) {
     return parsed;
 }
 
-static NfcCommand metroflip_scene_opal_poller_callback(NfcGenericEvent event, void* context) {
+static NfcCommand opal_poller_callback(NfcGenericEvent event, void* context) {
     furi_assert(event.protocol == NfcProtocolMfDesfire);
 
     Metroflip* app = context;
@@ -250,8 +252,7 @@ static NfcCommand metroflip_scene_opal_poller_callback(NfcGenericEvent event, vo
     return command;
 }
 
-void metroflip_scene_opal_on_enter(void* context) {
-    Metroflip* app = context;
+static void opal_on_enter(Metroflip* app) {
     dolphin_deed(DolphinDeedNfcRead);
 
     // Setup view
@@ -263,13 +264,12 @@ void metroflip_scene_opal_on_enter(void* context) {
     view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewPopup);
     nfc_scanner_alloc(app->nfc);
     app->poller = nfc_poller_alloc(app->nfc, NfcProtocolMfDesfire);
-    nfc_poller_start(app->poller, metroflip_scene_opal_poller_callback, app);
+    nfc_poller_start(app->poller, opal_poller_callback, app);
 
     metroflip_app_blink_start(app);
 }
 
-bool metroflip_scene_opal_on_event(void* context, SceneManagerEvent event) {
-    Metroflip* app = context;
+static bool opal_on_event(Metroflip* app, SceneManagerEvent event) {
     bool consumed = false;
 
     if(event.type == SceneManagerEventTypeCustom) {
@@ -298,8 +298,7 @@ bool metroflip_scene_opal_on_event(void* context, SceneManagerEvent event) {
     return consumed;
 }
 
-void metroflip_scene_opal_on_exit(void* context) {
-    Metroflip* app = context;
+static void opal_on_exit(Metroflip* app) {
     widget_reset(app->widget);
     metroflip_app_blink_stop(app);
     if(app->poller) {
@@ -307,3 +306,24 @@ void metroflip_scene_opal_on_exit(void* context) {
         nfc_poller_free(app->poller);
     }
 }
+
+/* Actual implementation of app<>plugin interface */
+static const MetroflipPlugin opal_plugin = {
+    .card_name = "Opal",
+    .plugin_on_enter = opal_on_enter,
+    .plugin_on_event = opal_on_event,
+    .plugin_on_exit = opal_on_exit,
+
+};
+
+/* Plugin descriptor to comply with basic plugin specification */
+static const FlipperAppPluginDescriptor opal_plugin_descriptor = {
+    .appid = METROFLIP_SUPPORTED_CARD_PLUGIN_APP_ID,
+    .ep_api_version = METROFLIP_SUPPORTED_CARD_PLUGIN_API_VERSION,
+    .entry_point = &opal_plugin,
+};
+
+/* Plugin entry point - must return a pointer to const descriptor  */
+const FlipperAppPluginDescriptor* opal_plugin_ep(void) {
+    return &opal_plugin_descriptor;
+}

+ 30 - 19
scenes/metroflip_scene_smartrider.c → scenes/plugins/smartrider.c

@@ -1,4 +1,4 @@
-#include "../metroflip_i.h"
+#include "../../metroflip_i.h"
 
 #include <bit_lib.h>
 #include <flipper_application.h>
@@ -13,6 +13,8 @@
 #include <nfc/nfc_device.h>
 #include <nfc/nfc_listener.h>
 #include <storage/storage.h>
+#include "../../api/metroflip/metroflip_api.h"
+#include "../../metroflip_plugins.h"
 
 #define MAX_TRIPS           10
 #define TAG                 "Metroflip:Scene:Smartrider"
@@ -143,12 +145,6 @@ static bool smartrider_parse(const NfcDevice* device, FuriString* parsed_data) {
         return false;
     }
 
-    const MfClassicSectorTrailer* sec_tr = mf_classic_get_sector_trailer_by_sector(data, 0);
-    if(!sec_tr || memcmp(sec_tr->key_a.data, SMARTRIDER_STANDARD_KEYS[0], 6) != 0) {
-        FURI_LOG_E(TAG, "Key verification failed for sector 0");
-        return false;
-    }
-
     static const uint8_t required_blocks[] = {14, 4, 5, 1, 52, 50, 0};
     for(size_t i = 0; i < COUNT_OF(required_blocks); i++) {
         if(required_blocks[i] >= MAX_BLOCKS ||
@@ -243,8 +239,7 @@ static bool smartrider_parse(const NfcDevice* device, FuriString* parsed_data) {
 
 // made with love by jay candel <3
 
-static NfcCommand
-    metroflip_scene_smartrider_poller_callback(NfcGenericEvent event, void* context) {
+static NfcCommand smartrider_poller_callback(NfcGenericEvent event, void* context) {
     furi_assert(context);
     furi_assert(event.event_data);
     furi_assert(event.protocol == NfcProtocolMfClassic);
@@ -337,8 +332,8 @@ static NfcCommand
     return command;
 }
 
-void metroflip_scene_smartrider_on_enter(void* context) {
-    Metroflip* app = context;
+static void smartrider_on_enter(Metroflip* app) {
+    FURI_LOG_I(TAG, "entered smartrider");
     dolphin_deed(DolphinDeedNfcRead);
 
     mf_classic_key_cache_reset(app->mfc_key_cache);
@@ -352,13 +347,12 @@ void metroflip_scene_smartrider_on_enter(void* context) {
     view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewPopup);
     nfc_scanner_alloc(app->nfc);
     app->poller = nfc_poller_alloc(app->nfc, NfcProtocolMfClassic);
-    nfc_poller_start(app->poller, metroflip_scene_smartrider_poller_callback, app);
+    nfc_poller_start(app->poller, smartrider_poller_callback, app);
 
     metroflip_app_blink_start(app);
 }
 
-bool metroflip_scene_smartrider_on_event(void* context, SceneManagerEvent event) {
-    Metroflip* app = context;
+static bool smartrider_on_event(Metroflip* app, SceneManagerEvent event) {
     bool consumed = false;
 
     if(event.type == SceneManagerEventTypeCustom) {
@@ -378,9 +372,6 @@ bool metroflip_scene_smartrider_on_event(void* context, SceneManagerEvent event)
             Popup* popup = app->popup;
             popup_set_header(popup, "Failed", 68, 30, AlignLeft, AlignTop);
             consumed = true;
-        } else if(event.event == MetroflipCustomEventPollerSuccess) {
-            scene_manager_next_scene(app->scene_manager, MetroflipSceneReadSuccess);
-            consumed = true;
         }
     } else if(event.type == SceneManagerEventTypeBack) {
         scene_manager_search_and_switch_to_previous_scene(app->scene_manager, MetroflipSceneStart);
@@ -390,8 +381,7 @@ bool metroflip_scene_smartrider_on_event(void* context, SceneManagerEvent event)
     return consumed;
 }
 
-void metroflip_scene_smartrider_on_exit(void* context) {
-    Metroflip* app = context;
+static void smartrider_on_exit(Metroflip* app) {
     widget_reset(app->widget);
 
     if(app->poller) {
@@ -404,3 +394,24 @@ void metroflip_scene_smartrider_on_exit(void* context) {
 
     metroflip_app_blink_stop(app);
 }
+
+/* Actual implementation of app<>plugin interface */
+static const MetroflipPlugin smartrider_plugin = {
+    .card_name = "SmartRider",
+    .plugin_on_enter = smartrider_on_enter,
+    .plugin_on_event = smartrider_on_event,
+    .plugin_on_exit = smartrider_on_exit,
+
+};
+
+/* Plugin descriptor to comply with basic plugin specification */
+static const FlipperAppPluginDescriptor smartrider_plugin_descriptor = {
+    .appid = METROFLIP_SUPPORTED_CARD_PLUGIN_APP_ID,
+    .ep_api_version = METROFLIP_SUPPORTED_CARD_PLUGIN_API_VERSION,
+    .entry_point = &smartrider_plugin,
+};
+
+/* Plugin entry point - must return a pointer to const descriptor  */
+const FlipperAppPluginDescriptor* smartrider_plugin_ep(void) {
+    return &smartrider_plugin_descriptor;
+}

+ 92 - 13
scenes/metroflip_scene_troika.c → scenes/plugins/troika.c

@@ -1,10 +1,10 @@
 #include <flipper_application.h>
-#include "../metroflip_i.h"
+#include "../../metroflip_i.h"
 
 #include <nfc/protocols/mf_classic/mf_classic_poller_sync.h>
 #include <nfc/protocols/mf_classic/mf_classic.h>
 #include <nfc/protocols/mf_classic/mf_classic_poller.h>
-#include "../api/mosgortrans/mosgortrans_util.h"
+#include "../../api/mosgortrans/mosgortrans_util.h"
 
 #include <dolphin/dolphin.h>
 #include <bit_lib.h>
@@ -12,9 +12,73 @@
 #include <nfc/nfc.h>
 #include <nfc/nfc_device.h>
 #include <nfc/nfc_listener.h>
+#include "../../api/metroflip/metroflip_api.h"
+#include "../../metroflip_plugins.h"
 
 #define TAG "Metroflip:Scene:Troika"
 
+const MfClassicKeyPair troika_1k_keys[16] = {
+    {.a = 0xa0a1a2a3a4a5, .b = 0xfbf225dc5d58},
+    {.a = 0xa82607b01c0d, .b = 0x2910989b6880},
+    {.a = 0x2aa05ed1856f, .b = 0xeaac88e5dc99},
+    {.a = 0x2aa05ed1856f, .b = 0xeaac88e5dc99},
+    {.a = 0x73068f118c13, .b = 0x2b7f3253fac5},
+    {.a = 0xfbc2793d540b, .b = 0xd3a297dc2698},
+    {.a = 0x2aa05ed1856f, .b = 0xeaac88e5dc99},
+    {.a = 0xae3d65a3dad4, .b = 0x0f1c63013dba},
+    {.a = 0xa73f5dc1d333, .b = 0xe35173494a81},
+    {.a = 0x69a32f1c2f19, .b = 0x6b8bd9860763},
+    {.a = 0x9becdf3d9273, .b = 0xf8493407799d},
+    {.a = 0x08b386463229, .b = 0x5efbaecef46b},
+    {.a = 0xcd4c61c26e3d, .b = 0x31c7610de3b0},
+    {.a = 0xa82607b01c0d, .b = 0x2910989b6880},
+    {.a = 0x0e8f64340ba4, .b = 0x4acec1205d75},
+    {.a = 0x2aa05ed1856f, .b = 0xeaac88e5dc99},
+};
+
+const MfClassicKeyPair troika_4k_keys[40] = {
+    {.a = 0xEC29806D9738, .b = 0xFBF225DC5D58}, //1
+    {.a = 0xA0A1A2A3A4A5, .b = 0x7DE02A7F6025}, //2
+    {.a = 0x2AA05ED1856F, .b = 0xEAAC88E5DC99}, //3
+    {.a = 0x2AA05ED1856F, .b = 0xEAAC88E5DC99}, //4
+    {.a = 0x73068F118C13, .b = 0x2B7F3253FAC5}, //5
+    {.a = 0xFBC2793D540B, .b = 0xD3A297DC2698}, //6
+    {.a = 0x2AA05ED1856F, .b = 0xEAAC88E5DC99}, //7
+    {.a = 0xAE3D65A3DAD4, .b = 0x0F1C63013DBA}, //8
+    {.a = 0xA73F5DC1D333, .b = 0xE35173494A81}, //9
+    {.a = 0x69A32F1C2F19, .b = 0x6B8BD9860763}, //10
+    {.a = 0x9BECDF3D9273, .b = 0xF8493407799D}, //11
+    {.a = 0x08B386463229, .b = 0x5EFBAECEF46B}, //12
+    {.a = 0xCD4C61C26E3D, .b = 0x31C7610DE3B0}, //13
+    {.a = 0xA82607B01C0D, .b = 0x2910989B6880}, //14
+    {.a = 0x0E8F64340BA4, .b = 0x4ACEC1205D75}, //15
+    {.a = 0x2AA05ED1856F, .b = 0xEAAC88E5DC99}, //16
+    {.a = 0x6B02733BB6EC, .b = 0x7038CD25C408}, //17
+    {.a = 0x403D706BA880, .b = 0xB39D19A280DF}, //18
+    {.a = 0xC11F4597EFB5, .b = 0x70D901648CB9}, //19
+    {.a = 0x0DB520C78C1C, .b = 0x73E5B9D9D3A4}, //20
+    {.a = 0x3EBCE0925B2F, .b = 0x372CC880F216}, //21
+    {.a = 0x16A27AF45407, .b = 0x9868925175BA}, //22
+    {.a = 0xABA208516740, .b = 0xCE26ECB95252}, //23
+    {.a = 0xCD64E567ABCD, .b = 0x8F79C4FD8A01}, //24
+    {.a = 0x764CD061F1E6, .b = 0xA74332F74994}, //25
+    {.a = 0x1CC219E9FEC1, .b = 0xB90DE525CEB6}, //26
+    {.a = 0x2FE3CB83EA43, .b = 0xFBA88F109B32}, //27
+    {.a = 0x07894FFEC1D6, .b = 0xEFCB0E689DB3}, //28
+    {.a = 0x04C297B91308, .b = 0xC8454C154CB5}, //29
+    {.a = 0x7A38E3511A38, .b = 0xAB16584C972A}, //30
+    {.a = 0x7545DF809202, .b = 0xECF751084A80}, //31
+    {.a = 0x5125974CD391, .b = 0xD3EAFB5DF46D}, //32
+    {.a = 0x7A86AA203788, .b = 0xE41242278CA2}, //33
+    {.a = 0xAFCEF64C9913, .b = 0x9DB96DCA4324}, //34
+    {.a = 0x04EAA462F70B, .b = 0xAC17B93E2FAE}, //35
+    {.a = 0xE734C210F27E, .b = 0x29BA8C3E9FDA}, //36
+    {.a = 0xD5524F591EED, .b = 0x5DAF42861B4D}, //37
+    {.a = 0xE4821A377B75, .b = 0xE8709E486465}, //38
+    {.a = 0x518DC6EEA089, .b = 0x97C64AC98CA4}, //39
+    {.a = 0xBB52F8CCE07F, .b = 0x6B6119752C70}, //40
+};
+
 static bool troika_get_card_config(TroikaCardConfig* config, MfClassicType type) {
     bool success = true;
 
@@ -85,7 +149,7 @@ static bool troika_parse(FuriString* parsed_data, const MfClassicData* data) {
 
 bool checked = false;
 
-static NfcCommand metroflip_scene_troika_poller_callback(NfcGenericEvent event, void* context) {
+static NfcCommand troika_poller_callback(NfcGenericEvent event, void* context) {
     furi_assert(context);
     furi_assert(event.event_data);
     furi_assert(event.protocol == NfcProtocolMfClassic);
@@ -170,8 +234,7 @@ static NfcCommand metroflip_scene_troika_poller_callback(NfcGenericEvent event,
     return command;
 }
 
-void metroflip_scene_troika_on_enter(void* context) {
-    Metroflip* app = context;
+static void troika_on_enter(Metroflip* app) {
     dolphin_deed(DolphinDeedNfcRead);
 
     app->sec_num = 0;
@@ -185,13 +248,12 @@ void metroflip_scene_troika_on_enter(void* context) {
     view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewPopup);
     nfc_scanner_alloc(app->nfc);
     app->poller = nfc_poller_alloc(app->nfc, NfcProtocolMfClassic);
-    nfc_poller_start(app->poller, metroflip_scene_troika_poller_callback, app);
+    nfc_poller_start(app->poller, troika_poller_callback, app);
 
     metroflip_app_blink_start(app);
 }
 
-bool metroflip_scene_troika_on_event(void* context, SceneManagerEvent event) {
-    Metroflip* app = context;
+static bool troika_on_event(Metroflip* app, SceneManagerEvent event) {
     bool consumed = false;
 
     if(event.type == SceneManagerEventTypeCustom) {
@@ -211,9 +273,6 @@ bool metroflip_scene_troika_on_event(void* context, SceneManagerEvent event) {
             Popup* popup = app->popup;
             popup_set_header(popup, "Failed", 68, 30, AlignLeft, AlignTop);
             consumed = true;
-        } else if(event.event == MetroflipCustomEventPollerSuccess) {
-            scene_manager_next_scene(app->scene_manager, MetroflipSceneReadSuccess);
-            consumed = true;
         }
     } else if(event.type == SceneManagerEventTypeBack) {
         scene_manager_search_and_switch_to_previous_scene(app->scene_manager, MetroflipSceneStart);
@@ -223,8 +282,7 @@ bool metroflip_scene_troika_on_event(void* context, SceneManagerEvent event) {
     return consumed;
 }
 
-void metroflip_scene_troika_on_exit(void* context) {
-    Metroflip* app = context;
+static void troika_on_exit(Metroflip* app) {
     widget_reset(app->widget);
 
     if(app->poller) {
@@ -237,3 +295,24 @@ void metroflip_scene_troika_on_exit(void* context) {
 
     metroflip_app_blink_stop(app);
 }
+
+/* Actual implementation of app<>plugin interface */
+static const MetroflipPlugin troika_plugin = {
+    .card_name = "Troika",
+    .plugin_on_enter = troika_on_enter,
+    .plugin_on_event = troika_on_event,
+    .plugin_on_exit = troika_on_exit,
+
+};
+
+/* Plugin descriptor to comply with basic plugin specification */
+static const FlipperAppPluginDescriptor troika_plugin_descriptor = {
+    .appid = METROFLIP_SUPPORTED_CARD_PLUGIN_APP_ID,
+    .ep_api_version = METROFLIP_SUPPORTED_CARD_PLUGIN_API_VERSION,
+    .entry_point = &troika_plugin,
+};
+
+/* Plugin entry point - must return a pointer to const descriptor  */
+const FlipperAppPluginDescriptor* troika_plugin_ep(void) {
+    return &troika_plugin_descriptor;
+}