Luu 1 год назад
Родитель
Сommit
e8c1d2f59e

+ 9 - 2
CHANGELOG.md

@@ -5,5 +5,12 @@
 ## v0.2
 
 - Update Rav-Kav parsing to show more data such as transaction logs
-- Add Navigo parser!
-- Bug fixes
+- Add Navigo parser! (Paris, France)
+- Bug fixes
+
+## v0.3
+
+- Added Clipper parser (San Francisco, CA, USA)
+- Added Troika parser (Moscow, Russia)
+- Added Myki parser (Melbourne (and surrounds), VIC, Australia)
+- Added Opal parser (Sydney (and surrounds), NSW, Australia)

+ 14 - 9
README.md

@@ -19,17 +19,19 @@ This is a list of metro cards and transit systems that need support or have part
   - Status: Fully supported.
 - [x] **Navigo**  
   - Status: Fully supported.
-
-## 🚧 In Progress / Needs More Functionality
-- [ ] **Rav-Kav**  
-  - Current functionality: Reads balance only.  
-  - To Do: Parse more data from the card (e.g., transaction history, expiration date, etc.).
+- [x] **Troika**
+  - Status: Fully supported.
+- [x] **Clipper**
+  - Status: Fully supported.
+- [x] **Myki**
+  - Status: Fully supported.
+- [x] **Opal**
+  - Status: Fully supported.
 
 ## 📝 To Do (Unimplemented)
 - [ ] **Tianjin Railway Transit (TRT)**  
   - To Do: Add support for reading and analyzing Tianjin Railway Transit cards.
-- [ ] **Clipper**  
-  - To Do: Add support for reading and analyzing Clipper cards.
+
 
 
 ---
@@ -41,5 +43,8 @@ This is a list of metro cards and transit systems that need support or have part
 - **Navigo Parser**: [@luu176](https://github.com/luu176)
 - **Metromoney Parser**: [@Leptopt1los](https://github.com/Leptopt1los)
 - **Bip! Parser**: [@rbasoalto](https://github.com/rbasoalto) [@gornekich](https://github.com/gornekich)
-- **Info Slave**: [@equipter](https://github.com/equipter)
-
+- **Clipper Parser**: [@ke6jjj](https://github.com/ke6jjj)
+- **Troika Parser**: [@gornekich](https://github.com/gornekich)
+- **Myki Parser**: [@gornekich](https://github.com/gornekich)
+- **Opal parser**: [@gornekich](https://github.com/gornekich)
+- **Info Slave**: [@equipter](https://github.com/equipter)

+ 1 - 1
api/mosgortrans/mosgortrans_util.c

@@ -1,6 +1,6 @@
 #include "mosgortrans_util.h"
 
-#define TAG "Mosgortrans"
+#define TAG "Metroflip:Scene:Mosgortrans"
 
 void render_section_header(
     FuriString* str,

+ 29 - 19
app/README.md

@@ -2,33 +2,43 @@
 Metroflip is a multi-protocol metro card reader app for the Flipper Zero, inspired by the Metrodroid project. It enables the parsing and analysis of metro cards from transit systems around the world, providing a proof-of-concept for exploring transit card data in a portable format.
 
 # Author
-[luu176](https://github.com/luu176)
+luu176
 
-# Metroflip - Card Support List
+# Card Support List
 
 This is a list of metro cards and transit systems that are supported.
 
-## Supported Cards
-- [x] **Rav-Kav**  
+## Supported Cards
+- **Rav-Kav**  
   - Status: Partially supported
-- [x] **Navigo**  
+- **Navigo**  
   - Status: Fully supported.
-- [x] **Charliecard**  
+- **Charliecard**  
   - Status: Fully supported.
-- [x] **Metromoney**  
+- **Metromoney**  
   - Status: Fully supported.
-- [x] **Bip!**  
+- **Bip!**  
+  - Status: Fully supported.
+- **Clipper**  
+  - Status: Fully supported.
+- **Troika**  
+  - Status: Fully supported.
+- **Myki**  
+  - Status: Fully supported.
+- **Opal**  
   - Status: Fully supported.
 
-## .. And more coming soon!
-
----
+More coming soon! 
 
-### Credits:
-- **App Author**: [@luu176](https://github.com/luu176)
-- **Charliecard Parser**: [@zacharyweiss](https://github.com/zacharyweiss)
-- **Rav-Kav Parser**: [@luu176](https://github.com/luu176)
-- **Navigo Parser**: [@luu176](https://github.com/luu176)
-- **Metromoney Parser**: [@Leptopt1los](https://github.com/Leptopt1los)
-- **Bip! Parser**: [@rbasoalto](https://github.com/rbasoalto) [@gornekich](https://github.com/gornekich)
-- **Info Slave**: [@equipter](https://github.com/equipter)
+## Credits:
+- **App Author**: luu176
+- **Charliecard Parser**: zacharyweiss
+- **Rav-Kav Parser**: luu176
+- **Navigo Parser**: luu176
+- **Metromoney Parser**: Leptopt1los
+- **Bip! Parser**: rbasoaltor & gornekich
+- **Clipper Parser**: ke6jjj
+- **Troika Parser**: gornekich
+- **Myki Parser**: gornekich
+- **Opal parser**: gornekich
+- **Info Slave**: equipter

+ 7 - 1
scenes/metroflip_scene_clipper.c

@@ -29,6 +29,8 @@
 #include <locale/locale.h>
 #include <inttypes.h>
 
+#define TAG "Metroflip:Scene:Clipper"
+
 //
 // Table of application ids observed in the wild, and their sources.
 //
@@ -575,7 +577,11 @@ static NfcCommand metroflip_scene_clipper_poller_callback(NfcGenericEvent event,
     if(mf_desfire_event->type == MfDesfirePollerEventTypeReadSuccess) {
         nfc_device_set_data(
             app->nfc_device, NfcProtocolMfDesfire, nfc_poller_get_data(app->poller));
-        clipper_parse(app->nfc_device, parsed_data);
+        if(!clipper_parse(app->nfc_device, parsed_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(

+ 2 - 0
scenes/metroflip_scene_config.h

@@ -8,5 +8,7 @@ ADD_SCENE(metroflip, read_success, ReadSuccess)
 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)

+ 205 - 0
scenes/metroflip_scene_itso.c

@@ -0,0 +1,205 @@
+/* itso.c - Parser for ITSO cards (United Kingdom). */
+#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 <applications/services/locale/locale.h>
+#include <datetime.h>
+
+#define TAG "Metroflip:Scene:ITSO"
+
+static const MfDesfireApplicationId itso_app_id = {.data = {0x16, 0x02, 0xa0}};
+static const MfDesfireFileId itso_file_id = 0x0f;
+
+int64_t swap_int64(int64_t val) {
+    val = ((val << 8) & 0xFF00FF00FF00FF00ULL) | ((val >> 8) & 0x00FF00FF00FF00FFULL);
+    val = ((val << 16) & 0xFFFF0000FFFF0000ULL) | ((val >> 16) & 0x0000FFFF0000FFFFULL);
+    return (val << 32) | ((val >> 32) & 0xFFFFFFFFULL);
+}
+
+uint64_t swap_uint64(uint64_t val) {
+    val = ((val << 8) & 0xFF00FF00FF00FF00ULL) | ((val >> 8) & 0x00FF00FF00FF00FFULL);
+    val = ((val << 16) & 0xFFFF0000FFFF0000ULL) | ((val >> 16) & 0x0000FFFF0000FFFFULL);
+    return (val << 32) | (val >> 32);
+}
+
+static bool itso_parse(const NfcDevice* device, FuriString* parsed_data) {
+    furi_assert(device);
+    furi_assert(parsed_data);
+
+    bool parsed = false;
+
+    do {
+        const MfDesfireData* data = nfc_device_get_data(device, NfcProtocolMfDesfire);
+
+        const MfDesfireApplication* app = mf_desfire_get_application(data, &itso_app_id);
+        if(app == NULL) break;
+
+        typedef struct {
+            uint64_t part1;
+            uint64_t part2;
+            uint64_t part3;
+            uint64_t part4;
+        } ItsoFile;
+
+        const MfDesfireFileSettings* file_settings =
+            mf_desfire_get_file_settings(app, &itso_file_id);
+
+        if(file_settings == NULL || file_settings->type != MfDesfireFileTypeStandard ||
+           file_settings->data.size < sizeof(ItsoFile))
+            break;
+
+        const MfDesfireFileData* file_data = mf_desfire_get_file_data(app, &itso_file_id);
+        if(file_data == NULL) break;
+
+        const ItsoFile* itso_file = simple_array_cget_data(file_data->data);
+
+        uint64_t x1 = swap_uint64(itso_file->part1);
+        uint64_t x2 = swap_uint64(itso_file->part2);
+
+        char cardBuff[32];
+        char dateBuff[18];
+
+        snprintf(cardBuff, sizeof(cardBuff), "%llx%llx", x1, x2);
+        snprintf(dateBuff, sizeof(dateBuff), "%llx", x2);
+
+        char* cardp = cardBuff + 4;
+        cardp[18] = '\0';
+
+        // All itso card numbers are prefixed with "633597"
+        if(strncmp(cardp, "633597", 6) != 0) break;
+
+        char* datep = dateBuff + 12;
+        dateBuff[17] = '\0';
+
+        // DateStamp is defined in BS EN 1545 - Days passed since 01/01/1997
+        uint32_t dateStamp;
+        if(strint_to_uint32(datep, NULL, &dateStamp, 16) != StrintParseNoError) {
+            return false;
+        }
+        uint32_t unixTimestamp = dateStamp * 24 * 60 * 60 + 852076800U;
+
+        furi_string_set(parsed_data, "\e#ITSO Card\n");
+
+        // Digit count in each space-separated group
+        static const uint8_t digit_count[] = {6, 4, 4, 4};
+
+        for(uint32_t i = 0, k = 0; i < COUNT_OF(digit_count); k += digit_count[i++]) {
+            for(uint32_t j = 0; j < digit_count[i]; ++j) {
+                furi_string_push_back(parsed_data, cardp[j + k]);
+            }
+            furi_string_push_back(parsed_data, ' ');
+        }
+
+        DateTime timestamp = {0};
+        datetime_timestamp_to_datetime(unixTimestamp, &timestamp);
+
+        FuriString* timestamp_str = furi_string_alloc();
+        locale_format_date(timestamp_str, &timestamp, locale_get_date_format(), "-");
+
+        furi_string_cat(parsed_data, "\nExpiry: ");
+        furi_string_cat(parsed_data, timestamp_str);
+
+        furi_string_free(timestamp_str);
+
+        parsed = true;
+    } while(false);
+
+    return parsed;
+}
+
+static NfcCommand metroflip_scene_itso_poller_callback(NfcGenericEvent event, void* context) {
+    furi_assert(event.protocol == NfcProtocolMfDesfire);
+
+    Metroflip* app = context;
+    NfcCommand command = NfcCommandContinue;
+
+    FuriString* parsed_data = furi_string_alloc();
+    Widget* widget = app->widget;
+    furi_string_reset(app->text_box_store);
+    const MfDesfirePollerEvent* mf_desfire_event = event.event_data;
+    if(mf_desfire_event->type == MfDesfirePollerEventTypeReadSuccess) {
+        nfc_device_set_data(
+            app->nfc_device, NfcProtocolMfDesfire, nfc_poller_get_data(app->poller));
+        if(!itso_parse(app->nfc_device, parsed_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);
+        metroflip_app_blink_stop(app);
+        command = NfcCommandStop;
+    } else if(mf_desfire_event->type == MfDesfirePollerEventTypeReadFailed) {
+        view_dispatcher_send_custom_event(app->view_dispatcher, MetroflipCustomEventPollerSuccess);
+        command = NfcCommandReset;
+    }
+
+    return command;
+}
+
+void metroflip_scene_itso_on_enter(void* context) {
+    Metroflip* app = context;
+    dolphin_deed(DolphinDeedNfcRead);
+
+    // 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);
+    nfc_scanner_alloc(app->nfc);
+    app->poller = nfc_poller_alloc(app->nfc, NfcProtocolMfDesfire);
+    nfc_poller_start(app->poller, metroflip_scene_itso_poller_callback, app);
+
+    metroflip_app_blink_start(app);
+}
+
+bool metroflip_scene_itso_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_itso_on_exit(void* context) {
+    Metroflip* app = context;
+    widget_reset(app->widget);
+
+    if(app->poller) {
+        nfc_poller_stop(app->poller);
+        nfc_poller_free(app->poller);
+    }
+}

+ 7 - 1
scenes/metroflip_scene_myki.c

@@ -6,6 +6,8 @@
 #include "../metroflip_i.h"
 #include <nfc/protocols/mf_desfire/mf_desfire_poller.h>
 
+#define TAG "Metroflip:Scene:Myki"
+
 static const MfDesfireApplicationId myki_app_id = {.data = {0x00, 0x11, 0xf2}};
 static const MfDesfireFileId myki_file_id = 0x0f;
 
@@ -105,7 +107,11 @@ static NfcCommand metroflip_scene_myki_poller_callback(NfcGenericEvent event, vo
     if(mf_desfire_event->type == MfDesfirePollerEventTypeReadSuccess) {
         nfc_device_set_data(
             app->nfc_device, NfcProtocolMfDesfire, nfc_poller_get_data(app->poller));
-        myki_parse(app->nfc_device, parsed_data);
+        if(!myki_parse(app->nfc_device, parsed_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(

+ 309 - 0
scenes/metroflip_scene_opal.c

@@ -0,0 +1,309 @@
+/*
+ * opal.c - Parser for Opal card (Sydney, Australia).
+ *
+ * Copyright 2023 Michael Farrell <micolous+git@gmail.com>
+ *
+ * This will only read "standard" MIFARE DESFire-based Opal cards. Free travel
+ * cards (including School Opal cards, veteran, vision-impaired persons and
+ * TfNSW employees' cards) and single-trip tickets are MIFARE Ultralight C
+ * cards and not supported.
+ *
+ * Reference: https://github.com/metrodroid/metrodroid/wiki/Opal
+ *
+ * Note: The card values are all little-endian (like Flipper), but the above
+ * reference was originally written based on Java APIs, which are big-endian.
+ * This implementation presumes a little-endian system.
+ *
+ * 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 "../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 <applications/services/locale/locale.h>
+#include <datetime.h>
+
+#define TAG "Metroflip:Scene:Opal"
+
+static const MfDesfireApplicationId opal_app_id = {.data = {0x31, 0x45, 0x53}};
+
+static const MfDesfireFileId opal_file_id = 0x07;
+
+static const char* opal_modes[5] =
+    {"Rail / Metro", "Ferry / Light Rail", "Bus", "Unknown mode", "Manly Ferry"};
+
+static const char* opal_usages[14] = {
+    "New / Unused",
+    "Tap on: new journey",
+    "Tap on: transfer from same mode",
+    "Tap on: transfer from other mode",
+    NULL, // Manly Ferry: new journey
+    NULL, // Manly Ferry: transfer from ferry
+    NULL, // Manly Ferry: transfer from other
+    "Tap off: distance fare",
+    "Tap off: flat fare",
+    "Automated tap off: failed to tap off",
+    "Tap off: end of trip without start",
+    "Tap off: reversal",
+    "Tap on: rejected",
+    "Unknown usage",
+};
+
+// Opal file 0x7 structure. Assumes a little-endian CPU.
+typedef struct FURI_PACKED {
+    uint32_t serial         : 32;
+    uint8_t check_digit     : 4;
+    bool blocked            : 1;
+    uint16_t txn_number     : 16;
+    int32_t balance         : 21;
+    uint16_t days           : 15;
+    uint16_t minutes        : 11;
+    uint8_t mode            : 3;
+    uint16_t usage          : 4;
+    bool auto_topup         : 1;
+    uint8_t weekly_journeys : 4;
+    uint16_t checksum       : 16;
+} OpalFile;
+
+static_assert(sizeof(OpalFile) == 16, "OpalFile");
+
+// Converts an Opal timestamp to DateTime.
+//
+// Opal measures days since 1980-01-01 and minutes since midnight, and presumes
+// all days are 1440 minutes.
+static void opal_days_minutes_to_datetime(uint16_t days, uint16_t minutes, DateTime* out) {
+    out->year = 1980;
+    out->month = 1;
+    // 1980-01-01 is a Tuesday
+    out->weekday = ((days + 1) % 7) + 1;
+    out->hour = minutes / 60;
+    out->minute = minutes % 60;
+    out->second = 0;
+
+    // What year is it?
+    for(;;) {
+        const uint16_t num_days_in_year = datetime_get_days_per_year(out->year);
+        if(days < num_days_in_year) break;
+        days -= num_days_in_year;
+        out->year++;
+    }
+
+    // 1-index the day of the year
+    days++;
+
+    for(;;) {
+        // What month is it?
+        const bool is_leap = datetime_is_leap_year(out->year);
+        const uint8_t num_days_in_month = datetime_get_days_per_month(is_leap, out->month);
+        if(days <= num_days_in_month) break;
+        days -= num_days_in_month;
+        out->month++;
+    }
+
+    out->day = days;
+}
+
+static bool opal_parse(const NfcDevice* device, FuriString* parsed_data) {
+    furi_assert(device);
+    furi_assert(parsed_data);
+
+    const MfDesfireData* data = nfc_device_get_data(device, NfcProtocolMfDesfire);
+
+    bool parsed = false;
+
+    do {
+        const MfDesfireApplication* app = mf_desfire_get_application(data, &opal_app_id);
+        if(app == NULL) break;
+
+        const MfDesfireFileSettings* file_settings =
+            mf_desfire_get_file_settings(app, &opal_file_id);
+        if(file_settings == NULL || file_settings->type != MfDesfireFileTypeStandard ||
+           file_settings->data.size != sizeof(OpalFile))
+            break;
+
+        const MfDesfireFileData* file_data = mf_desfire_get_file_data(app, &opal_file_id);
+        if(file_data == NULL) break;
+
+        const OpalFile* opal_file = simple_array_cget_data(file_data->data);
+
+        const uint8_t serial2 = opal_file->serial / 10000000;
+        const uint16_t serial3 = (opal_file->serial / 1000) % 10000;
+        const uint16_t serial4 = (opal_file->serial % 1000);
+
+        if(opal_file->check_digit > 9) break;
+
+        // Negative balance. Make this a positive value again and record the
+        // sign separately, because then we can handle balances of -99..-1
+        // cents, as the "dollars" division below would result in a positive
+        // zero value.
+        const bool is_negative_balance = (opal_file->balance < 0);
+        const char* sign = is_negative_balance ? "-" : "";
+        const int32_t balance = is_negative_balance ? labs(opal_file->balance) : //-V1081
+                                                      opal_file->balance;
+        const uint8_t balance_cents = balance % 100;
+        const int32_t balance_dollars = balance / 100;
+
+        DateTime timestamp;
+        opal_days_minutes_to_datetime(opal_file->days, opal_file->minutes, &timestamp);
+
+        // Usages 4..6 associated with the Manly Ferry, which correspond to
+        // usages 1..3 for other modes.
+        const bool is_manly_ferry = (opal_file->usage >= 4) && (opal_file->usage <= 6);
+
+        // 3..7 are "reserved", but we use 4 to indicate the Manly Ferry.
+        const uint8_t mode = is_manly_ferry ? 4 : opal_file->mode;
+        const uint8_t usage = is_manly_ferry ? opal_file->usage - 3 : opal_file->usage;
+
+        const char* mode_str = opal_modes[mode > 4 ? 3 : mode];
+        const char* usage_str = opal_usages[usage > 12 ? 13 : usage];
+
+        furi_string_printf(
+            parsed_data,
+            "\e#Opal: $%s%ld.%02hu\nNo.: 3085 22%02hhu %04hu %03hu%01hhu\n%s, %s\n",
+            sign,
+            balance_dollars,
+            balance_cents,
+            serial2,
+            serial3,
+            serial4,
+            opal_file->check_digit,
+            mode_str,
+            usage_str);
+
+        FuriString* timestamp_str = furi_string_alloc();
+
+        locale_format_date(timestamp_str, &timestamp, locale_get_date_format(), "-");
+        furi_string_cat(parsed_data, timestamp_str);
+        furi_string_cat(parsed_data, " at ");
+
+        locale_format_time(timestamp_str, &timestamp, locale_get_time_format(), false);
+        furi_string_cat(parsed_data, timestamp_str);
+
+        furi_string_free(timestamp_str);
+
+        furi_string_cat_printf(
+            parsed_data,
+            "\nWeekly journeys: %hhu, Txn #%hu\n",
+            opal_file->weekly_journeys,
+            opal_file->txn_number);
+
+        if(opal_file->auto_topup) {
+            furi_string_cat_str(parsed_data, "Auto-topup enabled\n");
+        }
+
+        if(opal_file->blocked) {
+            furi_string_cat_str(parsed_data, "Card blocked\n");
+        }
+
+        parsed = true;
+    } while(false);
+
+    return parsed;
+}
+
+static NfcCommand metroflip_scene_opal_poller_callback(NfcGenericEvent event, void* context) {
+    furi_assert(event.protocol == NfcProtocolMfDesfire);
+
+    Metroflip* app = context;
+    NfcCommand command = NfcCommandContinue;
+
+    FuriString* parsed_data = furi_string_alloc();
+    Widget* widget = app->widget;
+    furi_string_reset(app->text_box_store);
+    const MfDesfirePollerEvent* mf_desfire_event = event.event_data;
+    if(mf_desfire_event->type == MfDesfirePollerEventTypeReadSuccess) {
+        nfc_device_set_data(
+            app->nfc_device, NfcProtocolMfDesfire, nfc_poller_get_data(app->poller));
+        if(!opal_parse(app->nfc_device, parsed_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);
+        metroflip_app_blink_stop(app);
+        command = NfcCommandStop;
+    } else if(mf_desfire_event->type == MfDesfirePollerEventTypeReadFailed) {
+        view_dispatcher_send_custom_event(app->view_dispatcher, MetroflipCustomEventPollerSuccess);
+        command = NfcCommandReset;
+    }
+
+    return command;
+}
+
+void metroflip_scene_opal_on_enter(void* context) {
+    Metroflip* app = context;
+    dolphin_deed(DolphinDeedNfcRead);
+
+    // 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);
+    nfc_scanner_alloc(app->nfc);
+    app->poller = nfc_poller_alloc(app->nfc, NfcProtocolMfDesfire);
+    nfc_poller_start(app->poller, metroflip_scene_opal_poller_callback, app);
+
+    metroflip_app_blink_start(app);
+}
+
+bool metroflip_scene_opal_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_opal_on_exit(void* context) {
+    Metroflip* app = context;
+    widget_reset(app->widget);
+    metroflip_app_blink_stop(app);
+    if(app->poller) {
+        nfc_poller_stop(app->poller);
+        nfc_poller_free(app->poller);
+    }
+}

+ 6 - 0
scenes/metroflip_scene_start.c

@@ -33,6 +33,12 @@ void metroflip_scene_start_on_enter(void* context) {
     submenu_add_item(
         submenu, "Troika", MetroflipSceneTroika, metroflip_scene_start_submenu_callback, app);
 
+    submenu_add_item(
+        submenu, "Opal", MetroflipSceneOpal, metroflip_scene_start_submenu_callback, app);
+
+    submenu_add_item(
+        submenu, "ITSO", MetroflipSceneItso, metroflip_scene_start_submenu_callback, app);
+
     submenu_add_item(
         submenu,
         "Metromoney",