Просмотр исходного кода

Merge pull request #10 from luu176/dev

v0.3
Luu 1 год назад
Родитель
Сommit
7b9517fbc0

+ 10 - 1
CHANGELOG.md

@@ -1,8 +1,17 @@
 ## v0.1
 ## v0.1
 
 
 - Initial release by luu176
 - Initial release by luu176
+
 ## v0.2
 ## v0.2
 
 
 - Update Rav-Kav parsing to show more data such as transaction logs
 - Update Rav-Kav parsing to show more data such as transaction logs
-- Add Navigo parser!
+- Add Navigo parser! (Paris, France)
 - Bug fixes
 - 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)
+- Added ITSO parser (United Kingdom)

+ 18 - 12
README.md

@@ -10,7 +10,7 @@ This is a list of metro cards and transit systems that need support or have part
 
 
 ## ✅ Supported Cards
 ## ✅ Supported Cards
 - [x] **Rav-Kav**  
 - [x] **Rav-Kav**  
-  - Status: Needs more functionality (currently only able to read balance).
+  - Status: Partially Supported
 - [x] **Charliecard**  
 - [x] **Charliecard**  
   - Status: Fully supported.
   - Status: Fully supported.
 - [x] **Metromoney**  
 - [x] **Metromoney**  
@@ -18,19 +18,21 @@ This is a list of metro cards and transit systems that need support or have part
 - [x] **Bip!**  
 - [x] **Bip!**  
   - Status: Fully supported.
   - Status: Fully supported.
 - [x] **Navigo**  
 - [x] **Navigo**  
-  - Status: Fully supported. (v0.2)
-
-## 🚧 In Progress / Needs More Functionality
-- [ ] **Rav-Kav**  
-  - Current functionality: Reads balance only.  (v0.1)
-  - To Do: Parse more data from the card (e.g., transaction history, expiration date, etc.). (v0.2)
+  - Status: Fully supported.
+- [x] **Troika**
+  - Status: Fully supported.
+- [x] **Clipper**
+  - Status: Fully supported.
+- [x] **Myki**
+  - Status: Fully supported.
+- [x] **Opal**
+  - Status: Fully supported.
+- [x] **ITSO**
+  - Status: Fully supported.
 
 
 ## 📝 To Do (Unimplemented)
 ## 📝 To Do (Unimplemented)
 - [ ] **Tianjin Railway Transit (TRT)**  
 - [ ] **Tianjin Railway Transit (TRT)**  
   - To Do: Add support for reading and analyzing Tianjin Railway Transit cards.
   - To Do: Add support for reading and analyzing Tianjin Railway Transit cards.
-- [ ] **Clipper**  
-  - To Do: Add support for reading and analyzing Clipper cards. (v0.3)
-
 
 
 ---
 ---
 
 
@@ -41,5 +43,9 @@ This is a list of metro cards and transit systems that need support or have part
 - **Navigo Parser**: [@luu176](https://github.com/luu176)
 - **Navigo Parser**: [@luu176](https://github.com/luu176)
 - **Metromoney Parser**: [@Leptopt1los](https://github.com/Leptopt1los)
 - **Metromoney Parser**: [@Leptopt1los](https://github.com/Leptopt1los)
 - **Bip! Parser**: [@rbasoalto](https://github.com/rbasoalto) [@gornekich](https://github.com/gornekich)
 - **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)
+- **ITSO parser**: [@gsp8181](https://github.com/gsp8181), [@hedger](https://github.com/hedger), [@gornekich](https://github.com/gornekich)
+- **Info Slaves**: [@equipter](https://github.com/equipter), [TheDingo8MyBaby](https://github.com/TheDingo8MyBaby)

+ 1322 - 0
api/mosgortrans/mosgortrans_util.c

@@ -0,0 +1,1322 @@
+#include "mosgortrans_util.h"
+
+#define TAG "Metroflip:Scene:Mosgortrans"
+
+void render_section_header(
+    FuriString* str,
+    const char* name,
+    uint8_t prefix_separator_cnt,
+    uint8_t suffix_separator_cnt) {
+    for(uint8_t i = 0; i < prefix_separator_cnt; i++) {
+        furi_string_cat_printf(str, ":");
+    }
+    furi_string_cat_printf(str, "[ %s ]", name);
+    for(uint8_t i = 0; i < suffix_separator_cnt; i++) {
+        furi_string_cat_printf(str, ":");
+    }
+}
+
+void from_days_to_datetime(uint32_t days, DateTime* datetime, uint16_t start_year) {
+    uint32_t timestamp = days * 24 * 60 * 60;
+    DateTime start_datetime = {0};
+    start_datetime.year = start_year - 1;
+    start_datetime.month = 12;
+    start_datetime.day = 31;
+    timestamp += datetime_datetime_to_timestamp(&start_datetime);
+    datetime_timestamp_to_datetime(timestamp, datetime);
+}
+
+void from_minutes_to_datetime(uint32_t minutes, DateTime* datetime, uint16_t start_year) {
+    uint32_t timestamp = minutes * 60;
+    DateTime start_datetime = {0};
+    start_datetime.year = start_year - 1;
+    start_datetime.month = 12;
+    start_datetime.day = 31;
+    timestamp += datetime_datetime_to_timestamp(&start_datetime);
+    datetime_timestamp_to_datetime(timestamp, datetime);
+}
+
+void from_seconds_to_datetime(uint32_t seconds, DateTime* datetime, uint16_t start_year) {
+    uint32_t timestamp = seconds;
+    DateTime start_datetime = {0};
+    start_datetime.year = start_year - 1;
+    start_datetime.month = 12;
+    start_datetime.day = 31;
+    timestamp += datetime_datetime_to_timestamp(&start_datetime);
+    datetime_timestamp_to_datetime(timestamp, datetime);
+}
+
+typedef struct {
+    uint16_t view; //101
+    uint16_t type; //102
+    uint8_t layout; //111
+    uint8_t layout2; //112
+    uint16_t blank_type; //121
+    uint16_t type_of_extended; //122
+    uint8_t extended; //123
+    uint8_t benefit_code; //124
+    uint32_t number; //201
+    uint16_t use_before_date; //202
+    uint16_t use_before_date2; //202.2
+    uint16_t use_with_date; //205
+    uint8_t requires_activation; //301
+    uint16_t activate_during; //302
+    uint16_t extension_counter; //304
+    uint8_t blocked; //303
+    uint32_t valid_from_date; //311
+    uint16_t valid_to_date; //312
+    uint8_t valid_for_days; //313
+    uint32_t valid_for_minutes; //314
+    uint16_t valid_for_time; //316
+    uint16_t valid_for_time2; //316.2
+    uint32_t valid_to_time; //317
+    uint16_t remaining_trips; //321
+    uint8_t remaining_trips1; //321.1
+    uint32_t remaining_funds; //322
+    uint16_t total_trips; //331
+    uint16_t start_trip_date; //402
+    uint16_t start_trip_time; //403
+    uint32_t start_trip_neg_minutes; //404
+    uint32_t start_trip_minutes; //405
+    uint8_t start_trip_seconds; //406
+    uint8_t minutes_pass; //412
+    uint8_t passage_5_minutes; //413
+    uint8_t metro_ride_with; //414
+    uint8_t transport_type; //421
+    uint8_t transport_type_flag; //421.0
+    uint8_t transport_type1; //421.1
+    uint8_t transport_type2; //421.2
+    uint8_t transport_type3; //421.3
+    uint8_t transport_type4; //421.4
+    uint16_t validator; //422
+    uint8_t validator1; //422.1
+    uint16_t validator2; //422.2
+    uint16_t route; //424
+    uint8_t passage_in_metro; //431
+    uint8_t transfer_in_metro; //432
+    uint8_t passages_ground_transport; //433
+    uint8_t fare_trip; //441
+    uint16_t crc16; //501.1
+    uint16_t crc16_2; //501.2
+    uint32_t hash; //502
+    uint16_t hash1; //502.1
+    uint32_t hash2; //502.2
+    uint8_t geozone_a; //GeoZoneA
+    uint8_t geozone_b; //GeoZoneB
+    uint8_t company; //Company
+    uint8_t units; //Units
+    uint64_t rfu1; //rfu1
+    uint16_t rfu2; //rfu2
+    uint32_t rfu3; //rfu3
+    uint8_t rfu4; //rfu4
+    uint8_t rfu5; //rfu5
+    uint8_t write_enabled; //write_enabled
+    uint32_t tech_code; //TechCode
+    uint8_t interval; //Interval
+    uint16_t app_code1; //AppCode1
+    uint16_t app_code2; //AppCode2
+    uint16_t app_code3; //AppCode3
+    uint16_t app_code4; //AppCode4
+    uint16_t type1; //Type1
+    uint16_t type2; //Type2
+    uint16_t type3; //Type3
+    uint16_t type4; //Type4
+    uint8_t zoo; //zoo
+} BlockData;
+
+void parse_layout_2(BlockData* data_block, const MfClassicBlock* block) {
+    data_block->view = bit_lib_get_bits_16(block->data, 0x00, 10); //101
+    data_block->type = bit_lib_get_bits_16(block->data, 0x0A, 10); //102
+    data_block->number = bit_lib_get_bits_32(block->data, 0x14, 32); //201
+    data_block->layout = bit_lib_get_bits(block->data, 0x34, 4); //111
+    data_block->use_before_date = bit_lib_get_bits_16(block->data, 0x38, 16); //202
+    data_block->benefit_code = bit_lib_get_bits(block->data, 0x48, 8); //124
+    data_block->rfu1 = bit_lib_get_bits_32(block->data, 0x50, 32); //rfu1
+    data_block->crc16 = bit_lib_get_bits_16(block->data, 0x70, 16); //501.1
+    data_block->blocked = bit_lib_get_bits(block->data, 0x80, 1); //303
+    data_block->start_trip_time = bit_lib_get_bits_16(block->data, 0x81, 12); //403
+    data_block->start_trip_date = bit_lib_get_bits_16(block->data, 0x8D, 16); //402
+    data_block->valid_from_date = bit_lib_get_bits_16(block->data, 0x9D, 16); //311
+    data_block->valid_to_date = bit_lib_get_bits_16(block->data, 0xAD, 16); //312
+    data_block->start_trip_seconds = bit_lib_get_bits(block->data, 0xDB, 6); //406
+    data_block->transport_type1 = bit_lib_get_bits(block->data, 0xC3, 2); //421.1
+    data_block->transport_type2 = bit_lib_get_bits(block->data, 0xC5, 2); //421.2
+    data_block->transport_type3 = bit_lib_get_bits(block->data, 0xC7, 2); //421.3
+    data_block->transport_type4 = bit_lib_get_bits(block->data, 0xC9, 2); //421.4
+    data_block->use_with_date = bit_lib_get_bits_16(block->data, 0xBD, 16); //205
+    data_block->route = bit_lib_get_bits(block->data, 0xCD, 1); //424
+    data_block->validator1 = bit_lib_get_bits_16(block->data, 0xCE, 15); //422.1
+    data_block->validator = bit_lib_get_bits_16(block->data, 0xCD, 16);
+    data_block->total_trips = bit_lib_get_bits_16(block->data, 0xDD, 16); //331
+    data_block->write_enabled = bit_lib_get_bits(block->data, 0xED, 1); //write_enabled
+    data_block->rfu2 = bit_lib_get_bits(block->data, 0xEE, 2); //rfu2
+    data_block->crc16_2 = bit_lib_get_bits_16(block->data, 0xF0, 16); //501.2
+}
+
+void parse_layout_6(BlockData* data_block, const MfClassicBlock* block) {
+    data_block->view = bit_lib_get_bits_16(block->data, 0x00, 10); //101
+    data_block->type = bit_lib_get_bits_16(block->data, 0x0A, 10); //102
+    data_block->number = bit_lib_get_bits_32(block->data, 0x14, 32); //201
+    data_block->layout = bit_lib_get_bits(block->data, 0x34, 4); //111
+    data_block->use_before_date = bit_lib_get_bits_16(block->data, 0x38, 16); //202
+    data_block->geozone_a = bit_lib_get_bits(block->data, 0x48, 4); //GeoZoneA
+    data_block->geozone_b = bit_lib_get_bits(block->data, 0x4C, 4); //GeoZoneB
+    data_block->blank_type = bit_lib_get_bits_16(block->data, 0x50, 10); //121
+    data_block->type_of_extended = bit_lib_get_bits_16(block->data, 0x5A, 10); //122
+    data_block->rfu1 = bit_lib_get_bits_16(block->data, 0x64, 12); //rfu1
+    data_block->crc16 = bit_lib_get_bits_16(block->data, 0x70, 16); //501.1
+    data_block->blocked = bit_lib_get_bits(block->data, 0x80, 1); //303
+    data_block->start_trip_time = bit_lib_get_bits_16(block->data, 0x81, 12); //403
+    data_block->start_trip_date = bit_lib_get_bits_16(block->data, 0x8D, 16); //402
+    data_block->valid_from_date = bit_lib_get_bits_16(block->data, 0x9D, 16); //311
+    data_block->valid_to_date = bit_lib_get_bits_16(block->data, 0xAD, 16); //312
+    data_block->company = bit_lib_get_bits(block->data, 0xBD, 4); //Company
+    data_block->validator1 = bit_lib_get_bits(block->data, 0xC1, 4); //422.1
+    data_block->remaining_trips = bit_lib_get_bits_16(block->data, 0xC5, 10); //321
+    data_block->units = bit_lib_get_bits(block->data, 0xCF, 6); //Units
+    data_block->validator2 = bit_lib_get_bits_16(block->data, 0xD5, 10); //422.2
+    data_block->total_trips = bit_lib_get_bits_16(block->data, 0xDF, 16); //331
+    data_block->extended = bit_lib_get_bits(block->data, 0xEF, 1); //123
+    data_block->crc16_2 = bit_lib_get_bits_16(block->data, 0xF0, 16); //501.2
+}
+
+void parse_layout_8(BlockData* data_block, const MfClassicBlock* block) {
+    data_block->view = bit_lib_get_bits_16(block->data, 0x00, 10); //101
+    data_block->type = bit_lib_get_bits_16(block->data, 0x0A, 10); //102
+    data_block->number = bit_lib_get_bits_32(block->data, 0x14, 32); //201
+    data_block->layout = bit_lib_get_bits(block->data, 0x34, 4); //111
+    data_block->use_before_date = bit_lib_get_bits_16(block->data, 0x38, 16); //202
+    data_block->rfu1 = bit_lib_get_bits_64(block->data, 0x48, 56); //rfu1
+    data_block->valid_from_date = bit_lib_get_bits_16(block->data, 0x80, 16); //311
+    data_block->valid_for_days = bit_lib_get_bits(block->data, 0x90, 8); //313
+    data_block->requires_activation = bit_lib_get_bits(block->data, 0x98, 1); //301
+    data_block->rfu2 = bit_lib_get_bits(block->data, 0x99, 7); //rfu2
+    data_block->remaining_trips1 = bit_lib_get_bits(block->data, 0xA0, 8); //321.1
+    data_block->remaining_trips = bit_lib_get_bits(block->data, 0xA8, 8); //321
+    data_block->validator1 = bit_lib_get_bits(block->data, 0xB0, 2); //422.1
+    data_block->validator = bit_lib_get_bits_16(block->data, 0xB1, 15); //422
+    data_block->hash = bit_lib_get_bits_32(block->data, 0xC0, 32); //502
+    data_block->rfu3 = bit_lib_get_bits_32(block->data, 0xE0, 32); //rfu3
+}
+
+void parse_layout_A(BlockData* data_block, const MfClassicBlock* block) {
+    data_block->view = bit_lib_get_bits_16(block->data, 0x00, 10); //101
+    data_block->type = bit_lib_get_bits_16(block->data, 0x0A, 10); //102
+    data_block->number = bit_lib_get_bits_32(block->data, 0x14, 32); //201
+    data_block->layout = bit_lib_get_bits(block->data, 0x34, 4); //111
+    data_block->valid_from_date = bit_lib_get_bits_16(block->data, 0x40, 12); //311
+    data_block->valid_for_minutes = bit_lib_get_bits_32(block->data, 0x4C, 19); //314
+    data_block->requires_activation = bit_lib_get_bits(block->data, 0x5F, 1); //301
+    data_block->start_trip_minutes = bit_lib_get_bits_32(block->data, 0x60, 19); //405
+    data_block->minutes_pass = bit_lib_get_bits(block->data, 0x77, 7); //412
+    data_block->transport_type_flag = bit_lib_get_bits(block->data, 0x7E, 2); //421.0
+    data_block->remaining_trips = bit_lib_get_bits(block->data, 0x80, 8); //321
+    data_block->validator = bit_lib_get_bits_16(block->data, 0x88, 16); //422
+    data_block->transport_type1 = bit_lib_get_bits(block->data, 0x98, 2); //421.1
+    data_block->transport_type2 = bit_lib_get_bits(block->data, 0x9A, 2); //421.2
+    data_block->transport_type3 = bit_lib_get_bits(block->data, 0x9C, 2); //421.3
+    data_block->transport_type4 = bit_lib_get_bits(block->data, 0x9E, 2); //421.4
+    data_block->hash = bit_lib_get_bits_32(block->data, 0xC0, 32); //502
+}
+
+void parse_layout_C(BlockData* data_block, const MfClassicBlock* block) {
+    data_block->view = bit_lib_get_bits_16(block->data, 0x00, 10); //101
+    data_block->type = bit_lib_get_bits_16(block->data, 0x0A, 10); //102
+    data_block->number = bit_lib_get_bits_32(block->data, 0x14, 32); //201
+    data_block->layout = bit_lib_get_bits(block->data, 0x34, 4); //111
+    data_block->use_before_date = bit_lib_get_bits_16(block->data, 0x38, 16); //202
+    data_block->rfu1 = bit_lib_get_bits_64(block->data, 0x48, 56); //rfu1
+    data_block->valid_from_date = bit_lib_get_bits_16(block->data, 0x80, 16); //311
+    data_block->valid_for_days = bit_lib_get_bits(block->data, 0x90, 8); //313
+    data_block->requires_activation = bit_lib_get_bits(block->data, 0x98, 1); //301
+    data_block->rfu2 = bit_lib_get_bits_16(block->data, 0x99, 13); //rfu2
+    data_block->remaining_trips = bit_lib_get_bits_16(block->data, 0xA6, 10); //321
+    data_block->validator = bit_lib_get_bits_16(block->data, 0xB0, 16); //422
+    data_block->hash = bit_lib_get_bits_32(block->data, 0xC0, 32); //502
+    data_block->start_trip_date = bit_lib_get_bits_16(block->data, 0xE0, 16); //402
+    data_block->start_trip_time = bit_lib_get_bits_16(block->data, 0xF0, 11); //403
+    data_block->transport_type = bit_lib_get_bits(block->data, 0xFB, 2); //421
+    data_block->rfu3 = bit_lib_get_bits(block->data, 0xFD, 2); //rfu3
+    data_block->transfer_in_metro = bit_lib_get_bits(block->data, 0xFF, 1); //432
+}
+
+void parse_layout_D(BlockData* data_block, const MfClassicBlock* block) {
+    data_block->view = bit_lib_get_bits_16(block->data, 0x00, 10); //101
+    data_block->type = bit_lib_get_bits_16(block->data, 0x0A, 10); //102
+    data_block->number = bit_lib_get_bits_32(block->data, 0x14, 32); //201
+    data_block->layout = bit_lib_get_bits(block->data, 0x34, 4); //111
+    data_block->rfu1 = bit_lib_get_bits(block->data, 0x38, 8); //rfu1
+    data_block->use_before_date = bit_lib_get_bits_16(block->data, 0x40, 16); //202
+    data_block->valid_for_time = bit_lib_get_bits_16(block->data, 0x50, 11); //316
+    data_block->rfu2 = bit_lib_get_bits(block->data, 0x5B, 5); //rfu2
+    data_block->use_before_date2 = bit_lib_get_bits_16(block->data, 0x60, 16); //202.2
+    data_block->valid_for_time2 = bit_lib_get_bits_16(block->data, 0x70, 11); //316.2
+    data_block->rfu3 = bit_lib_get_bits(block->data, 0x7B, 5); //rfu3
+    data_block->valid_from_date = bit_lib_get_bits_16(block->data, 0x80, 16); //311
+    data_block->valid_for_days = bit_lib_get_bits(block->data, 0x90, 8); //313
+    data_block->requires_activation = bit_lib_get_bits(block->data, 0x98, 1); //301
+    data_block->rfu4 = bit_lib_get_bits(block->data, 0x99, 2); //rfu4
+    data_block->passage_5_minutes = bit_lib_get_bits(block->data, 0x9B, 5); //413
+    data_block->transport_type1 = bit_lib_get_bits(block->data, 0xA0, 2); //421.1
+    data_block->passage_in_metro = bit_lib_get_bits(block->data, 0xA2, 1); //431
+    data_block->passages_ground_transport = bit_lib_get_bits(block->data, 0xA3, 3); //433
+    data_block->remaining_trips = bit_lib_get_bits_16(block->data, 0xA6, 10); //321
+    data_block->validator = bit_lib_get_bits_16(block->data, 0xB0, 16); //422
+    data_block->hash = bit_lib_get_bits_32(block->data, 0xC0, 32); //502
+    data_block->start_trip_date = bit_lib_get_bits_16(block->data, 0xE0, 16); //402
+    data_block->start_trip_time = bit_lib_get_bits_16(block->data, 0xF0, 11); //403
+    data_block->transport_type2 = bit_lib_get_bits(block->data, 0xFB, 2); //421.2
+    data_block->rfu5 = bit_lib_get_bits(block->data, 0xFD, 2); //rfu5
+    data_block->transfer_in_metro = bit_lib_get_bits(block->data, 0xFF, 1); //432
+}
+
+void parse_layout_E1(BlockData* data_block, const MfClassicBlock* block) {
+    data_block->view = bit_lib_get_bits_16(block->data, 0x00, 10); //101
+    data_block->type = bit_lib_get_bits_16(block->data, 0x0A, 10); //102
+    data_block->number = bit_lib_get_bits_32(block->data, 0x14, 32); //201
+    data_block->layout = bit_lib_get_bits(block->data, 0x34, 4); //111
+    data_block->layout2 = bit_lib_get_bits(block->data, 0x38, 5); //112
+    data_block->use_before_date = bit_lib_get_bits_16(block->data, 0x3D, 16); //202
+    data_block->blank_type = bit_lib_get_bits_16(block->data, 0x4D, 10); //121
+    data_block->validator = bit_lib_get_bits_16(block->data, 0x80, 16); //422
+    data_block->start_trip_date = bit_lib_get_bits_16(block->data, 0x90, 16); //402
+    data_block->start_trip_time = bit_lib_get_bits_16(block->data, 0xA0, 11); //403
+    data_block->transport_type1 = bit_lib_get_bits(block->data, 0xAB, 2); //421.1
+    data_block->transport_type2 = bit_lib_get_bits(block->data, 0xAD, 2); //421.2
+    data_block->transfer_in_metro = bit_lib_get_bits(block->data, 0xB1, 1); //432
+    data_block->passage_in_metro = bit_lib_get_bits(block->data, 0xB2, 1); //431
+    data_block->passages_ground_transport = bit_lib_get_bits(block->data, 0xB3, 3); //433
+    data_block->minutes_pass = bit_lib_get_bits(block->data, 0xB9, 8); //412
+    data_block->remaining_funds = bit_lib_get_bits_32(block->data, 0xC4, 19); //322
+    data_block->fare_trip = bit_lib_get_bits(block->data, 0xD7, 2); //441
+    data_block->blocked = bit_lib_get_bits(block->data, 0x9D, 1); //303
+    data_block->zoo = bit_lib_get_bits(block->data, 0xDA, 1); //zoo
+    data_block->hash = bit_lib_get_bits_32(block->data, 0xE0, 32); //502
+}
+
+void parse_layout_E2(BlockData* data_block, const MfClassicBlock* block) {
+    data_block->view = bit_lib_get_bits_16(block->data, 0x00, 10); //101
+    data_block->type = bit_lib_get_bits_16(block->data, 0x0A, 10); //102
+    data_block->number = bit_lib_get_bits_32(block->data, 0x14, 32); //201
+    data_block->layout = bit_lib_get_bits(block->data, 0x34, 4); //111
+    data_block->layout2 = bit_lib_get_bits(block->data, 0x38, 5); //112
+    data_block->type_of_extended = bit_lib_get_bits_16(block->data, 0x3D, 10); //122
+    data_block->use_before_date = bit_lib_get_bits_16(block->data, 0x47, 16); //202
+    data_block->blank_type = bit_lib_get_bits_16(block->data, 0x57, 10); //121
+    data_block->valid_from_date = bit_lib_get_bits_16(block->data, 0x61, 16); //311
+    data_block->activate_during = bit_lib_get_bits_16(block->data, 0x71, 9); //302
+    data_block->valid_for_minutes = bit_lib_get_bits_32(block->data, 0x83, 20); //314
+    data_block->minutes_pass = bit_lib_get_bits(block->data, 0x9A, 8); //412
+    data_block->transport_type = bit_lib_get_bits(block->data, 0xA3, 2); //421
+    data_block->passage_in_metro = bit_lib_get_bits(block->data, 0xA5, 1); //431
+    data_block->transfer_in_metro = bit_lib_get_bits(block->data, 0xA6, 1); //432
+    data_block->remaining_trips = bit_lib_get_bits_16(block->data, 0xA7, 10); //321
+    data_block->validator = bit_lib_get_bits_16(block->data, 0xB1, 16); //422
+    data_block->start_trip_neg_minutes = bit_lib_get_bits_32(block->data, 0xC4, 20); //404
+    data_block->requires_activation = bit_lib_get_bits(block->data, 0xD8, 1); //301
+    data_block->blocked = bit_lib_get_bits(block->data, 0xD9, 1); //303
+    data_block->extended = bit_lib_get_bits(block->data, 0xDA, 1); //123
+    data_block->hash = bit_lib_get_bits_32(block->data, 0xE0, 32); //502
+}
+
+void parse_layout_E3(BlockData* data_block, const MfClassicBlock* block) {
+    data_block->view = bit_lib_get_bits_16(block->data, 0x00, 10); //101
+    data_block->type = bit_lib_get_bits_16(block->data, 0x0A, 10); //102
+    data_block->number = bit_lib_get_bits_32(block->data, 0x14, 32); //201
+    data_block->layout = bit_lib_get_bits(block->data, 0x34, 4); //111
+    data_block->layout2 = bit_lib_get_bits(block->data, 0x38, 5); //112
+    data_block->use_before_date = bit_lib_get_bits_16(block->data, 61, 16); //202
+    data_block->blank_type = bit_lib_get_bits_16(block->data, 0x4D, 10); //121
+    data_block->remaining_funds = bit_lib_get_bits_32(block->data, 0xBC, 22); //322
+    data_block->hash = bit_lib_get_bits_32(block->data, 224, 32); //502
+    data_block->validator = bit_lib_get_bits_16(block->data, 0x80, 16); //422
+    data_block->start_trip_minutes = bit_lib_get_bits_32(block->data, 0x90, 23); //405
+    data_block->fare_trip = bit_lib_get_bits(block->data, 0xD2, 2); //441
+    data_block->minutes_pass = bit_lib_get_bits(block->data, 0xAB, 7); //412
+    data_block->transport_type_flag = bit_lib_get_bits(block->data, 0xB2, 2); //421.0
+    data_block->transport_type1 = bit_lib_get_bits(block->data, 0xB4, 2); //421.1
+    data_block->transport_type2 = bit_lib_get_bits(block->data, 0xB6, 2); //421.2
+    data_block->transport_type3 = bit_lib_get_bits(block->data, 0xB8, 2); //421.3
+    data_block->transport_type4 = bit_lib_get_bits(block->data, 0xBA, 2); //421.4
+    data_block->blocked = bit_lib_get_bits(block->data, 0xD4, 1); //303
+}
+
+void parse_layout_E4(BlockData* data_block, const MfClassicBlock* block) {
+    data_block->view = bit_lib_get_bits_16(block->data, 0x00, 10); //101
+    data_block->type = bit_lib_get_bits_16(block->data, 0x0A, 10); //102
+    data_block->number = bit_lib_get_bits_32(block->data, 0x14, 32); //201
+    data_block->layout = bit_lib_get_bits(block->data, 0x34, 4); //111
+    data_block->layout2 = bit_lib_get_bits(block->data, 0x38, 5); //112
+    data_block->type_of_extended = bit_lib_get_bits_16(block->data, 0x3D, 10); //122
+    data_block->use_before_date = bit_lib_get_bits_16(block->data, 0x47, 13); //202
+    data_block->blank_type = bit_lib_get_bits_16(block->data, 0x54, 10); //121
+    data_block->valid_from_date = bit_lib_get_bits_16(block->data, 0x5E, 13); //311
+    data_block->activate_during = bit_lib_get_bits_16(block->data, 0x6B, 9); //302
+    data_block->extension_counter = bit_lib_get_bits_16(block->data, 0x74, 10); //304
+    data_block->valid_for_minutes = bit_lib_get_bits_32(block->data, 0x80, 20); //314
+    data_block->minutes_pass = bit_lib_get_bits(block->data, 0x98, 7); //412
+    data_block->transport_type_flag = bit_lib_get_bits(block->data, 0x9F, 2); //421.0
+    data_block->transport_type1 = bit_lib_get_bits(block->data, 0xA1, 2); //421.1
+    data_block->transport_type2 = bit_lib_get_bits(block->data, 0xA3, 2); //421.2
+    data_block->transport_type3 = bit_lib_get_bits(block->data, 0xA5, 2); //421.3
+    data_block->transport_type4 = bit_lib_get_bits(block->data, 0xA7, 2); //421.4
+    data_block->remaining_trips = bit_lib_get_bits_16(block->data, 0xA9, 10); //321
+    data_block->validator = bit_lib_get_bits_16(block->data, 0xB3, 16); //422
+    data_block->start_trip_neg_minutes = bit_lib_get_bits_32(block->data, 0xC3, 20); //404
+    data_block->requires_activation = bit_lib_get_bits(block->data, 0xD7, 1); //301
+    data_block->blocked = bit_lib_get_bits(block->data, 0xD8, 1); //303
+    data_block->extended = bit_lib_get_bits(block->data, 0xD9, 1); //123
+    data_block->hash = bit_lib_get_bits_32(block->data, 0xE0, 32); //502
+}
+
+void parse_layout_E5(BlockData* data_block, const MfClassicBlock* block) {
+    data_block->view = bit_lib_get_bits_16(block->data, 0x00, 10); //101
+    data_block->type = bit_lib_get_bits_16(block->data, 0x0A, 10); //102
+    data_block->number = bit_lib_get_bits_32(block->data, 0x14, 32); //201
+    data_block->layout = bit_lib_get_bits(block->data, 0x34, 4); //111
+    data_block->layout2 = bit_lib_get_bits(block->data, 0x38, 5); //112
+    data_block->use_before_date = bit_lib_get_bits_16(block->data, 0x3D, 13); //202
+    data_block->blank_type = bit_lib_get_bits_16(block->data, 0x4A, 10); //121
+    data_block->valid_to_time = bit_lib_get_bits_32(block->data, 0x54, 23); //317
+    data_block->extension_counter = bit_lib_get_bits_16(block->data, 0x6B, 10); //304
+    data_block->start_trip_minutes = bit_lib_get_bits_32(block->data, 0x80, 23); //405
+    data_block->metro_ride_with = bit_lib_get_bits(block->data, 0x97, 7); //414
+    data_block->minutes_pass = bit_lib_get_bits(block->data, 0x9E, 7); //412
+    data_block->remaining_funds = bit_lib_get_bits_32(block->data, 0xA7, 19); //322
+    data_block->validator = bit_lib_get_bits_16(block->data, 0xBA, 16); //422
+    data_block->blocked = bit_lib_get_bits(block->data, 0xCA, 1); //303
+    data_block->route = bit_lib_get_bits_16(block->data, 0xCC, 12); //424
+    data_block->passages_ground_transport = bit_lib_get_bits(block->data, 0xD8, 7); //433
+    data_block->hash = bit_lib_get_bits_32(block->data, 0xE0, 32); //502
+}
+
+void parse_layout_E6(BlockData* data_block, const MfClassicBlock* block) {
+    data_block->view = bit_lib_get_bits_16(block->data, 0x00, 10); //101
+    data_block->type = bit_lib_get_bits_16(block->data, 0x0A, 10); //102
+    data_block->number = bit_lib_get_bits_32(block->data, 0x14, 32); //201
+    data_block->layout = bit_lib_get_bits(block->data, 0x34, 4); //111
+    data_block->layout2 = bit_lib_get_bits(block->data, 0x38, 5); //112
+    data_block->type_of_extended = bit_lib_get_bits_16(block->data, 0x3D, 10); //122
+    data_block->use_before_date = bit_lib_get_bits_16(block->data, 0x47, 13); //202
+    data_block->blank_type = bit_lib_get_bits_16(block->data, 0x54, 10); //121
+    data_block->valid_from_date = bit_lib_get_bits_32(block->data, 0x5E, 23); //311
+    data_block->extension_counter = bit_lib_get_bits_16(block->data, 0x75, 10); //304
+    data_block->valid_for_minutes = bit_lib_get_bits_32(block->data, 0x80, 20); //314
+    data_block->start_trip_neg_minutes = bit_lib_get_bits_32(block->data, 0x94, 20); //404
+    data_block->metro_ride_with = bit_lib_get_bits(block->data, 0xA8, 7); //414
+    data_block->minutes_pass = bit_lib_get_bits(block->data, 0xAF, 7); //412
+    data_block->remaining_trips = bit_lib_get_bits(block->data, 0xB6, 7); //321
+    data_block->validator = bit_lib_get_bits_16(block->data, 0xBD, 16); //422
+    data_block->blocked = bit_lib_get_bits(block->data, 0xCD, 1); //303
+    data_block->extended = bit_lib_get_bits(block->data, 0xCE, 1); //123
+    data_block->route = bit_lib_get_bits_16(block->data, 0xD4, 12); //424
+    data_block->hash = bit_lib_get_bits_32(block->data, 0xE0, 32); //502
+}
+
+void parse_layout_FCB(BlockData* data_block, const MfClassicBlock* block) {
+    data_block->view = bit_lib_get_bits_16(block->data, 0x00, 10); //101
+    data_block->type = bit_lib_get_bits_16(block->data, 0x0A, 10); //102
+    data_block->number = bit_lib_get_bits_32(block->data, 0x14, 32); //201
+    data_block->layout = bit_lib_get_bits(block->data, 0x34, 4); //111
+    data_block->tech_code = bit_lib_get_bits_32(block->data, 0x38, 10); //tech_code
+    data_block->valid_from_date = bit_lib_get_bits_16(block->data, 0x42, 16); //311
+    data_block->valid_to_date = bit_lib_get_bits_16(block->data, 0x52, 16); //312
+    data_block->interval = bit_lib_get_bits(block->data, 0x62, 4); //interval
+    data_block->app_code1 = bit_lib_get_bits_16(block->data, 0x66, 10); //app_code1
+    data_block->hash1 = bit_lib_get_bits_16(block->data, 0x70, 16); //502.1
+    data_block->type1 = bit_lib_get_bits_16(block->data, 0x80, 10); //type1
+    data_block->app_code2 = bit_lib_get_bits_16(block->data, 0x8A, 10); //app_code2
+    data_block->type2 = bit_lib_get_bits_16(block->data, 0x94, 10); //type2
+    data_block->app_code3 = bit_lib_get_bits_16(block->data, 0x9E, 10); //app_code3
+    data_block->type3 = bit_lib_get_bits_16(block->data, 0xA8, 10); //type3
+    data_block->app_code4 = bit_lib_get_bits_16(block->data, 0xB2, 10); //app_code4
+    data_block->type4 = bit_lib_get_bits_16(block->data, 0xBC, 10); //type4
+    data_block->hash2 = bit_lib_get_bits_32(block->data, 0xE0, 32); //502.2
+}
+
+void parse_layout_F0B(BlockData* data_block, const MfClassicBlock* block) {
+    data_block->view = bit_lib_get_bits_16(block->data, 0x00, 10); //101
+    data_block->type = bit_lib_get_bits_16(block->data, 0x0A, 10); //102
+    data_block->number = bit_lib_get_bits_32(block->data, 0x14, 32); //201
+    data_block->layout = bit_lib_get_bits(block->data, 0x34, 4); //111
+    data_block->tech_code = bit_lib_get_bits_32(block->data, 0x38, 10); //tech_code
+    data_block->valid_from_date = bit_lib_get_bits_16(block->data, 0x42, 16); //311
+    data_block->valid_to_date = bit_lib_get_bits_16(block->data, 0x52, 16); //312
+    data_block->hash1 = bit_lib_get_bits_32(block->data, 0x70, 16); //502.1
+}
+
+void parse_transport_type(BlockData* data_block, FuriString* transport) {
+    switch(data_block->transport_type_flag) {
+    case 1:
+        uint8_t transport_type =
+            (data_block->transport_type1 || data_block->transport_type2 ||
+             data_block->transport_type3 || data_block->transport_type4);
+        switch(transport_type) {
+        case 1:
+            furi_string_cat(transport, "Metro");
+            break;
+        case 2:
+            furi_string_cat(transport, "Monorail");
+            break;
+        case 3:
+            furi_string_cat(transport, "MCC");
+            break;
+        default:
+            furi_string_cat(transport, "Unknown");
+            break;
+        }
+        break;
+    case 2:
+        furi_string_cat(transport, "Ground");
+        break;
+    default:
+        furi_string_cat(transport, "");
+        break;
+    }
+}
+
+bool mosgortrans_parse_transport_block(const MfClassicBlock* block, FuriString* result) {
+    BlockData data_block = {};
+    const uint16_t valid_departments[] = {0x106, 0x108, 0x10A, 0x10E, 0x110, 0x117};
+    uint16_t transport_department = bit_lib_get_bits_16(block->data, 0, 10);
+    if(furi_hal_rtc_is_flag_set(FuriHalRtcFlagDebug)) {
+        furi_string_cat_printf(result, "Transport department: %x\n", transport_department);
+    }
+    bool department_valid = false;
+    for(uint8_t i = 0; i < 6; i++) {
+        if(transport_department == valid_departments[i]) {
+            department_valid = true;
+            break;
+        }
+    }
+    if(!department_valid) {
+        return false;
+    }
+    FURI_LOG_D(TAG, "Transport department: %x", transport_department);
+    uint16_t layout_type = bit_lib_get_bits_16(block->data, 52, 4);
+    if(layout_type == 0xE) {
+        layout_type = bit_lib_get_bits_16(block->data, 52, 9);
+    } else if(layout_type == 0xF) {
+        layout_type = bit_lib_get_bits_16(block->data, 52, 14);
+    }
+    if(furi_hal_rtc_is_flag_set(FuriHalRtcFlagDebug)) {
+        furi_string_cat_printf(result, "Layout: %x\n", layout_type);
+    }
+    FURI_LOG_D(TAG, "Layout type %x", layout_type);
+    switch(layout_type) {
+    case 0x02: {
+        parse_layout_2(&data_block, block);
+        //number
+        furi_string_cat_printf(result, "Number: %010lu\n", data_block.number);
+        //use_before_date
+        DateTime card_use_before_date_s = {0};
+        from_days_to_datetime(data_block.use_before_date, &card_use_before_date_s, 1992);
+        furi_string_cat_printf(
+            result,
+            "Use before: %02d.%02d.%04d\n",
+            card_use_before_date_s.day,
+            card_use_before_date_s.month,
+            card_use_before_date_s.year);
+
+        if(data_block.valid_from_date == 0 || data_block.valid_to_date == 0) {
+            furi_string_cat(result, "\e#No ticket");
+            return false;
+        }
+        //remaining_trips
+        furi_string_cat_printf(result, "Trips: %d\n", data_block.total_trips);
+        //valid_from_date
+        DateTime card_valid_from_date_s = {0};
+        from_days_to_datetime(data_block.valid_from_date, &card_valid_from_date_s, 1992);
+        furi_string_cat_printf(
+            result,
+            "Valid from: %02d.%02d.%04d\n",
+            card_valid_from_date_s.day,
+            card_valid_from_date_s.month,
+            card_valid_from_date_s.year);
+        //valid_to_date
+        DateTime card_valid_to_date_s = {0};
+        from_days_to_datetime(data_block.valid_to_date, &card_valid_to_date_s, 1992);
+        furi_string_cat_printf(
+            result,
+            "Valid to: %02d.%02d.%04d\n",
+            card_valid_to_date_s.day,
+            card_valid_to_date_s.month,
+            card_valid_to_date_s.year);
+        //trip_number
+        furi_string_cat_printf(result, "Trips: %d\n", data_block.total_trips);
+        //trip_from
+        DateTime card_start_trip_minutes_s = {0};
+        from_seconds_to_datetime(
+            data_block.start_trip_date * 24 * 60 * 60 + data_block.start_trip_time * 60 +
+                data_block.start_trip_seconds,
+            &card_start_trip_minutes_s,
+            1992);
+        furi_string_cat_printf(
+            result,
+            "Trip from: %02d.%02d.%04d %02d:%02d",
+            card_start_trip_minutes_s.day,
+            card_start_trip_minutes_s.month,
+            card_start_trip_minutes_s.year,
+            card_start_trip_minutes_s.hour,
+            card_start_trip_minutes_s.minute);
+        break;
+    }
+    case 0x06: {
+        parse_layout_6(&data_block, block);
+        //number
+        furi_string_cat_printf(result, "Number: %010lu\n", data_block.number);
+        //use_before_date
+        DateTime card_use_before_date_s = {0};
+        from_days_to_datetime(data_block.use_before_date, &card_use_before_date_s, 1992);
+        furi_string_cat_printf(
+            result,
+            "Use before: %02d.%02d.%04d\n",
+            card_use_before_date_s.day,
+            card_use_before_date_s.month,
+            card_use_before_date_s.year);
+        //remaining_trips
+        furi_string_cat_printf(result, "Trips left: %d\n", data_block.remaining_trips);
+        //valid_from_date
+        DateTime card_valid_from_date_s = {0};
+        from_days_to_datetime(data_block.valid_from_date, &card_valid_from_date_s, 1992);
+        furi_string_cat_printf(
+            result,
+            "Valid from: %02d.%02d.%04d\n",
+            card_valid_from_date_s.day,
+            card_valid_from_date_s.month,
+            card_valid_from_date_s.year);
+        //valid_to_date
+        DateTime card_valid_to_date_s = {0};
+        from_days_to_datetime(data_block.valid_to_date, &card_valid_to_date_s, 1992);
+        furi_string_cat_printf(
+            result,
+            "Valid to: %02d.%02d.%04d\n",
+            card_valid_to_date_s.day,
+            card_valid_to_date_s.month,
+            card_valid_to_date_s.year);
+        //trip_number
+        furi_string_cat_printf(result, "Trips: %d\n", data_block.total_trips);
+        //trip_from
+        DateTime card_start_trip_minutes_s = {0};
+        from_minutes_to_datetime(
+            (data_block.start_trip_date) * 24 * 60 + data_block.start_trip_time,
+            &card_start_trip_minutes_s,
+            1992);
+        furi_string_cat_printf(
+            result,
+            "Trip from: %02d.%02d.%04d %02d:%02d\n",
+            card_start_trip_minutes_s.day,
+            card_start_trip_minutes_s.month,
+            card_start_trip_minutes_s.year,
+            card_start_trip_minutes_s.hour,
+            card_start_trip_minutes_s.minute);
+        //validator
+        furi_string_cat_printf(
+            result, "Validator: %05d", data_block.validator1 * 1024 + data_block.validator2);
+        break;
+    }
+    case 0x08: {
+        parse_layout_8(&data_block, block);
+        //number
+        furi_string_cat_printf(result, "Number: %010lu\n", data_block.number);
+        //use_before_date
+        DateTime card_use_before_date_s = {0};
+        from_days_to_datetime(data_block.use_before_date, &card_use_before_date_s, 1992);
+        //remaining_trips
+        furi_string_cat_printf(result, "Trips left: %d\n", data_block.remaining_trips);
+        //valid_from_date
+        DateTime card_valid_from_date_s = {0};
+        from_days_to_datetime(data_block.valid_from_date, &card_valid_from_date_s, 1992);
+        furi_string_cat_printf(
+            result,
+            "Valid from: %02d.%02d.%04d\n",
+            card_valid_from_date_s.day,
+            card_valid_from_date_s.month,
+            card_valid_from_date_s.year);
+        //valid_to_date
+        DateTime card_valid_to_date_s = {0};
+        from_days_to_datetime(
+            data_block.valid_from_date + data_block.valid_for_days, &card_valid_to_date_s, 1992);
+        furi_string_cat_printf(
+            result,
+            "Valid to: %02d.%02d.%04d",
+            card_valid_to_date_s.day,
+            card_valid_to_date_s.month,
+            card_valid_to_date_s.year);
+        break;
+    }
+    case 0x0A: {
+        parse_layout_A(&data_block, block);
+        //number
+        furi_string_cat_printf(result, "Number: %010lu\n", data_block.number);
+        //use_before_date
+        DateTime card_use_before_date_s = {0};
+        from_days_to_datetime(data_block.use_before_date, &card_use_before_date_s, 2016);
+        furi_string_cat_printf(
+            result,
+            "Use before: %02d.%02d.%04d\n",
+            card_use_before_date_s.day,
+            card_use_before_date_s.month,
+            card_use_before_date_s.year);
+        //remaining_trips
+        furi_string_cat_printf(result, "Trips left: %d\n", data_block.remaining_trips);
+        //valid_from_date
+        DateTime card_valid_from_date_s = {0};
+        from_days_to_datetime(data_block.valid_from_date, &card_valid_from_date_s, 2016);
+        furi_string_cat_printf(
+            result,
+            "Valid from: %02d.%02d.%04d\n",
+            card_valid_from_date_s.day,
+            card_valid_from_date_s.month,
+            card_valid_from_date_s.year);
+        //valid_to_date
+        DateTime card_valid_to_date_s = {0};
+        from_minutes_to_datetime(
+            data_block.valid_from_date * 24 * 60 + data_block.valid_for_minutes - 1,
+            &card_valid_to_date_s,
+            2016);
+        furi_string_cat_printf(
+            result,
+            "Valid to: %02d.%02d.%04d",
+            card_valid_to_date_s.day,
+            card_valid_to_date_s.month,
+            card_valid_to_date_s.year);
+        //trip_from
+        if(data_block.start_trip_minutes) {
+            DateTime card_start_trip_minutes_s = {0};
+            from_minutes_to_datetime(
+                data_block.valid_from_date * 24 * 60 + data_block.start_trip_minutes,
+                &card_start_trip_minutes_s,
+                2016);
+            furi_string_cat_printf(
+                result,
+                "\nTrip from: %02d.%02d.%04d %02d:%02d",
+                card_start_trip_minutes_s.day,
+                card_start_trip_minutes_s.month,
+                card_start_trip_minutes_s.year,
+                card_start_trip_minutes_s.hour,
+                card_start_trip_minutes_s.minute);
+        }
+        //trip_switch
+        if(data_block.minutes_pass) {
+            DateTime card_start_switch_trip_minutes_s = {0};
+            from_minutes_to_datetime(
+                data_block.valid_from_date * 24 * 60 + data_block.start_trip_minutes +
+                    data_block.minutes_pass,
+                &card_start_switch_trip_minutes_s,
+                2016);
+            furi_string_cat_printf(
+                result,
+                "\nTrip switch: %02d.%02d.%04d %02d:%02d",
+                card_start_switch_trip_minutes_s.day,
+                card_start_switch_trip_minutes_s.month,
+                card_start_switch_trip_minutes_s.year,
+                card_start_switch_trip_minutes_s.hour,
+                card_start_switch_trip_minutes_s.minute);
+        }
+        //transport
+        FuriString* transport = furi_string_alloc();
+        parse_transport_type(&data_block, transport);
+        furi_string_cat_printf(result, "\nTransport: %s", furi_string_get_cstr(transport));
+        //validator
+        if(data_block.validator) {
+            furi_string_cat_printf(result, "\nValidator: %05d", data_block.validator);
+        }
+        furi_string_free(transport);
+        break;
+    }
+    case 0x0C: {
+        parse_layout_C(&data_block, block);
+        //number
+        furi_string_cat_printf(result, "Number: %010lu\n", data_block.number);
+        //use_before_date
+        DateTime card_use_before_date_s = {0};
+        from_days_to_datetime(data_block.use_before_date, &card_use_before_date_s, 1992);
+        //remaining_trips
+        furi_string_cat_printf(result, "Trips left: %d\n", data_block.remaining_trips);
+        //valid_from_date
+        DateTime card_valid_from_date_s = {0};
+        from_days_to_datetime(data_block.valid_from_date, &card_valid_from_date_s, 1992);
+        furi_string_cat_printf(
+            result,
+            "Valid from: %02d.%02d.%04d\n",
+            card_valid_from_date_s.day,
+            card_valid_from_date_s.month,
+            card_valid_from_date_s.year);
+        //valid_to_date
+        DateTime card_valid_to_date_s = {0};
+        from_days_to_datetime(
+            data_block.valid_from_date + data_block.valid_for_days, &card_valid_to_date_s, 1992);
+        furi_string_cat_printf(
+            result,
+            "Valid to: %02d.%02d.%04d\n",
+            card_valid_to_date_s.day,
+            card_valid_to_date_s.month,
+            card_valid_to_date_s.year);
+        //remaining_trips
+        furi_string_cat_printf(result, "Trips left: %d", data_block.remaining_trips);
+        //trip_from
+        if(data_block.start_trip_date) { // TODO: (-nofl) unused
+            DateTime card_start_trip_minutes_s = {0};
+            from_minutes_to_datetime(
+                data_block.start_trip_date * 24 * 60 + data_block.start_trip_time,
+                &card_start_trip_minutes_s,
+                1992);
+        }
+        //validator
+        if(data_block.validator) {
+            furi_string_cat_printf(result, "\nValidator: %05d", data_block.validator);
+        }
+        break;
+    }
+    case 0x0D: {
+        parse_layout_D(&data_block, block);
+        //number
+        furi_string_cat_printf(result, "Number: %010lu\n", data_block.number);
+        //use_before_date
+        DateTime card_use_before_date_s = {0};
+        from_days_to_datetime(data_block.use_before_date, &card_use_before_date_s, 1992);
+        furi_string_cat_printf(
+            result,
+            "Use before: %02d.%02d.%04d\n",
+            card_use_before_date_s.day,
+            card_use_before_date_s.month,
+            card_use_before_date_s.year);
+        //remaining_trips
+        furi_string_cat_printf(result, "Trips left: %d\n", data_block.remaining_trips);
+        //valid_from_date
+        DateTime card_valid_from_date_s = {0};
+        from_days_to_datetime(data_block.valid_from_date, &card_valid_from_date_s, 1992);
+        furi_string_cat_printf(
+            result,
+            "Valid from: %02d.%02d.%04d\n",
+            card_valid_from_date_s.day,
+            card_valid_from_date_s.month,
+            card_valid_from_date_s.year);
+        //valid_to_date
+        DateTime card_valid_to_date_s = {0};
+        from_days_to_datetime(
+            data_block.valid_from_date + data_block.valid_for_days, &card_valid_to_date_s, 1992);
+        furi_string_cat_printf(
+            result,
+            "Valid to: %02d.%02d.%04d",
+            card_valid_to_date_s.day,
+            card_valid_to_date_s.month,
+            card_valid_to_date_s.year);
+        //trip_from
+        if(data_block.start_trip_date) { // TODO: (-nofl) unused
+            DateTime card_start_trip_minutes_s = {0};
+            from_minutes_to_datetime(
+                data_block.start_trip_date * 24 * 60 + data_block.start_trip_time,
+                &card_start_trip_minutes_s,
+                1992);
+        }
+        //trip_switch
+        if(data_block.passage_5_minutes) { // TODO: (-nofl) unused
+            DateTime card_start_switch_trip_minutes_s = {0};
+            from_minutes_to_datetime(
+                data_block.start_trip_date * 24 * 60 + data_block.start_trip_time +
+                    data_block.passage_5_minutes,
+                &card_start_switch_trip_minutes_s,
+                1992);
+        }
+        //validator
+        if(data_block.validator) {
+            furi_string_cat_printf(result, "\nValidator: %05d", data_block.validator);
+        }
+        break;
+    }
+    case 0xE1:
+    case 0x1C1: {
+        parse_layout_E1(&data_block, block);
+        //number
+        furi_string_cat_printf(result, "Number: %010lu\n", data_block.number);
+        //use_before_date
+        DateTime card_use_before_date_s = {0};
+        from_days_to_datetime(data_block.use_before_date, &card_use_before_date_s, 1992);
+        furi_string_cat_printf(
+            result,
+            "Use before: %02d.%02d.%04d\n",
+            card_use_before_date_s.day,
+            card_use_before_date_s.month,
+            card_use_before_date_s.year);
+        //remaining_funds
+        furi_string_cat_printf(result, "Balance: %ld rub\n", data_block.remaining_funds / 100);
+        //trip_from
+        if(data_block.start_trip_date) {
+            DateTime card_start_trip_minutes_s = {0};
+            from_minutes_to_datetime(
+                data_block.start_trip_date * 24 * 60 + data_block.start_trip_time,
+                &card_start_trip_minutes_s,
+                1992);
+            furi_string_cat_printf(
+                result,
+                "Trip from: %02d.%02d.%04d %02d:%02d\n",
+                card_start_trip_minutes_s.day,
+                card_start_trip_minutes_s.month,
+                card_start_trip_minutes_s.year,
+                card_start_trip_minutes_s.hour,
+                card_start_trip_minutes_s.minute);
+        }
+        //transport
+        FuriString* transport = furi_string_alloc();
+        switch(data_block.transport_type1) {
+        case 1:
+            switch(data_block.transport_type2) {
+            case 1:
+                furi_string_cat(transport, "Metro");
+                break;
+            case 2:
+                furi_string_cat(transport, "Monorail");
+                break;
+            default:
+                furi_string_cat(transport, "Unknown");
+                break;
+            }
+            break;
+        case 2:
+            furi_string_cat(transport, "Ground");
+            break;
+        case 3:
+            furi_string_cat(transport, "MCC");
+            break;
+        default:
+            furi_string_cat(transport, "");
+            break;
+        }
+        furi_string_cat_printf(result, "Transport: %s", furi_string_get_cstr(transport));
+        //validator
+        if(data_block.validator) {
+            furi_string_cat_printf(result, "\nValidator: %05d", data_block.validator);
+        }
+        furi_string_free(transport);
+        break;
+    }
+    case 0xE2:
+    case 0x1C2: {
+        parse_layout_E2(&data_block, block);
+        //number
+        furi_string_cat_printf(result, "Number: %010lu\n", data_block.number);
+        //use_before_date
+        DateTime card_use_before_date_s = {0};
+        from_days_to_datetime(data_block.use_before_date, &card_use_before_date_s, 1992);
+        furi_string_cat_printf(
+            result,
+            "Use before: %02d.%02d.%04d\n",
+            card_use_before_date_s.day,
+            card_use_before_date_s.month,
+            card_use_before_date_s.year);
+        //remaining_trips
+        furi_string_cat_printf(result, "Trips left: %d\n", data_block.remaining_trips);
+        //valid_from_date
+        DateTime card_valid_from_date_s = {0};
+        from_days_to_datetime(data_block.valid_from_date, &card_valid_from_date_s, 1992);
+        furi_string_cat_printf(
+            result,
+            "Valid from: %02d.%02d.%04d",
+            card_valid_from_date_s.day,
+            card_valid_from_date_s.month,
+            card_valid_from_date_s.year);
+        //valid_to_date
+        if(data_block.activate_during) {
+            DateTime card_valid_to_date_s = {0};
+            from_days_to_datetime(
+                data_block.valid_from_date + data_block.activate_during,
+                &card_valid_to_date_s,
+                1992);
+            furi_string_cat_printf(
+                result,
+                "\nValid to: %02d.%02d.%04d",
+                card_valid_to_date_s.day,
+                card_valid_to_date_s.month,
+                card_valid_to_date_s.year);
+        } else {
+            DateTime card_valid_to_date_s = {0};
+            from_minutes_to_datetime(
+                data_block.valid_from_date * 24 * 60 + data_block.valid_for_minutes - 1,
+                &card_valid_to_date_s,
+                1992);
+            furi_string_cat_printf(
+                result,
+                "\nValid to: %02d.%02d.%04d",
+                card_valid_to_date_s.day,
+                card_valid_to_date_s.month,
+                card_valid_to_date_s.year);
+        }
+        //trip_from
+        if(data_block.start_trip_neg_minutes) {
+            DateTime card_start_trip_minutes_s = {0};
+            from_minutes_to_datetime(
+                data_block.valid_to_date * 24 * 60 + data_block.valid_for_minutes -
+                    data_block.start_trip_neg_minutes,
+                &card_start_trip_minutes_s,
+                1992); //-time
+            furi_string_cat_printf(
+                result,
+                "\nTrip from: %02d.%02d.%04d %02d:%02d",
+                card_start_trip_minutes_s.day,
+                card_start_trip_minutes_s.month,
+                card_start_trip_minutes_s.year,
+                card_start_trip_minutes_s.hour,
+                card_start_trip_minutes_s.minute);
+        }
+        //trip_switch
+        if(data_block.minutes_pass) {
+            DateTime card_start_switch_trip_minutes_s = {0};
+            from_minutes_to_datetime(
+                data_block.valid_from_date * 24 * 60 + data_block.valid_for_minutes -
+                    data_block.start_trip_neg_minutes + data_block.minutes_pass,
+                &card_start_switch_trip_minutes_s,
+                1992);
+            furi_string_cat_printf(
+                result,
+                "\nTrip switch: %02d.%02d.%04d %02d:%02d",
+                card_start_switch_trip_minutes_s.day,
+                card_start_switch_trip_minutes_s.month,
+                card_start_switch_trip_minutes_s.year,
+                card_start_switch_trip_minutes_s.hour,
+                card_start_switch_trip_minutes_s.minute);
+        }
+        //transport
+        FuriString* transport = furi_string_alloc();
+        switch(data_block.transport_type) { // TODO: (-nofl) unused
+        case 1:
+            furi_string_cat(transport, "Metro");
+            break;
+        case 2:
+            furi_string_cat(transport, "Monorail");
+            break;
+        case 3:
+            furi_string_cat(transport, "Ground");
+            break;
+        default:
+            furi_string_cat(transport, "Unknown");
+            break;
+        }
+        //validator
+        if(data_block.validator) {
+            furi_string_cat_printf(result, "\nValidator: %05d", data_block.validator);
+        }
+        furi_string_free(transport);
+        break;
+    }
+    case 0xE3:
+    case 0x1C3: {
+        parse_layout_E3(&data_block, block);
+        // number
+        furi_string_cat_printf(result, "Number: %010lu\n", data_block.number);
+        // use_before_date
+        DateTime card_use_before_date_s = {0};
+        from_days_to_datetime(data_block.use_before_date, &card_use_before_date_s, 1992);
+        furi_string_cat_printf(
+            result,
+            "Use before: %02d.%02d.%04d\n",
+            card_use_before_date_s.day,
+            card_use_before_date_s.month,
+            card_use_before_date_s.year);
+        // remaining_funds
+        furi_string_cat_printf(result, "Balance: %lu rub\n", data_block.remaining_funds);
+        // start_trip_minutes
+        DateTime card_start_trip_minutes_s = {0};
+        from_minutes_to_datetime(data_block.start_trip_minutes, &card_start_trip_minutes_s, 2016);
+        furi_string_cat_printf(
+            result,
+            "Trip from: %02d.%02d.%04d %02d:%02d\n",
+            card_start_trip_minutes_s.day,
+            card_start_trip_minutes_s.month,
+            card_start_trip_minutes_s.year,
+            card_start_trip_minutes_s.hour,
+            card_start_trip_minutes_s.minute);
+        // transport
+        FuriString* transport = furi_string_alloc();
+        parse_transport_type(&data_block, transport);
+        furi_string_cat_printf(result, "Transport: %s\n", furi_string_get_cstr(transport));
+        // validator
+        furi_string_cat_printf(result, "Validator: %05d\n", data_block.validator);
+        // fare
+        FuriString* fare = furi_string_alloc();
+        switch(data_block.fare_trip) {
+        case 0:
+            furi_string_cat(fare, "");
+            break;
+        case 1:
+            furi_string_cat(fare, "Single");
+            break;
+        case 2:
+            furi_string_cat(fare, "90 minutes");
+            break;
+        default:
+            furi_string_cat(fare, "Unknown");
+            break;
+        }
+        furi_string_cat_printf(result, "Fare: %s", furi_string_get_cstr(fare));
+        furi_string_free(fare);
+        furi_string_free(transport);
+        break;
+    }
+    case 0xE4:
+    case 0x1C4: {
+        parse_layout_E4(&data_block, block);
+
+        // number
+        furi_string_cat_printf(result, "Number: %010lu\n", data_block.number);
+        // use_before_date
+        DateTime card_use_before_date_s = {0};
+        from_days_to_datetime(data_block.use_before_date, &card_use_before_date_s, 2016);
+        furi_string_cat_printf(
+            result,
+            "Use before: %02d.%02d.%04d\n",
+            card_use_before_date_s.day,
+            card_use_before_date_s.month,
+            card_use_before_date_s.year);
+        // remaining_funds
+        furi_string_cat_printf(result, "Balance: %lu rub\n", data_block.remaining_funds);
+        // valid_from_date
+        DateTime card_use_from_date_s = {0};
+        from_days_to_datetime(data_block.valid_from_date, &card_use_from_date_s, 2016);
+        furi_string_cat_printf(
+            result,
+            "Valid from: %02d.%02d.%04d\n",
+            card_use_from_date_s.day,
+            card_use_from_date_s.month,
+            card_use_from_date_s.year);
+        // valid_to_date
+        DateTime card_use_to_date_s = {0};
+        if(data_block.requires_activation) {
+            from_days_to_datetime(
+                data_block.valid_from_date + data_block.activate_during,
+                &card_use_to_date_s,
+                2016);
+        } else {
+            from_minutes_to_datetime(
+                data_block.valid_from_date * 24 * 60 + data_block.valid_for_minutes - 1,
+                &card_use_to_date_s,
+                2016);
+        }
+
+        furi_string_cat_printf(
+            result,
+            "Valid to: %02d.%02d.%04d\n",
+            card_use_to_date_s.day,
+            card_use_to_date_s.month,
+            card_use_to_date_s.year);
+        // trip_number
+        // furi_string_cat_printf(result, "Trips left: %d", data_block.remaining_trips);
+        // trip_from
+        DateTime card_start_trip_minutes_s = {0};
+        from_minutes_to_datetime(
+            data_block.valid_from_date * 24 * 60 + data_block.valid_for_minutes -
+                data_block.start_trip_neg_minutes,
+            &card_start_trip_minutes_s,
+            2016); // TODO: (-nofl) unused
+        //transport
+        FuriString* transport = furi_string_alloc();
+        parse_transport_type(&data_block, transport);
+        furi_string_cat_printf(result, "Transport: %s", furi_string_get_cstr(transport));
+        // validator
+        if(data_block.validator) {
+            furi_string_cat_printf(result, "\nValidator: %05d", data_block.validator);
+        }
+        furi_string_free(transport);
+        break;
+    }
+    case 0xE5:
+    case 0x1C5: {
+        parse_layout_E5(&data_block, block);
+        //number
+        furi_string_cat_printf(result, "Number: %010lu\n", data_block.number);
+        //use_before_date
+        DateTime card_use_before_date_s = {0};
+        from_days_to_datetime(data_block.use_before_date, &card_use_before_date_s, 2019);
+        furi_string_cat_printf(
+            result,
+            "Use before: %02d.%02d.%04d\n",
+            card_use_before_date_s.day,
+            card_use_before_date_s.month,
+            card_use_before_date_s.year);
+        //remaining_funds
+        furi_string_cat_printf(result, "Balance: %ld rub", data_block.remaining_funds / 100);
+        //start_trip_minutes
+        if(data_block.start_trip_minutes) {
+            DateTime card_start_trip_minutes_s = {0};
+            from_minutes_to_datetime(
+                data_block.start_trip_minutes, &card_start_trip_minutes_s, 2019);
+            furi_string_cat_printf(
+                result,
+                "\nTrip from: %02d.%02d.%04d %02d:%02d",
+                card_start_trip_minutes_s.day,
+                card_start_trip_minutes_s.month,
+                card_start_trip_minutes_s.year,
+                card_start_trip_minutes_s.hour,
+                card_start_trip_minutes_s.minute);
+        }
+        //start_m_trip_minutes
+        if(data_block.metro_ride_with) {
+            DateTime card_start_m_trip_minutes_s = {0};
+            from_minutes_to_datetime(
+                data_block.start_trip_minutes + data_block.metro_ride_with,
+                &card_start_m_trip_minutes_s,
+                2019);
+            furi_string_cat_printf(
+                result,
+                "\n(M) from: %02d.%02d.%04d %02d:%02d",
+                card_start_m_trip_minutes_s.day,
+                card_start_m_trip_minutes_s.month,
+                card_start_m_trip_minutes_s.year,
+                card_start_m_trip_minutes_s.hour,
+                card_start_m_trip_minutes_s.minute);
+        }
+        if(data_block.minutes_pass) {
+            DateTime card_start_change_trip_minutes_s = {0};
+            from_minutes_to_datetime(
+                data_block.start_trip_minutes + data_block.minutes_pass,
+                &card_start_change_trip_minutes_s,
+                2019);
+            furi_string_cat_printf(
+                result,
+                "\nTrip edit: %02d.%02d.%04d %02d:%02d",
+                card_start_change_trip_minutes_s.day,
+                card_start_change_trip_minutes_s.month,
+                card_start_change_trip_minutes_s.year,
+                card_start_change_trip_minutes_s.hour,
+                card_start_change_trip_minutes_s.minute);
+        }
+        //transport
+        //validator
+        if(data_block.validator) {
+            furi_string_cat_printf(result, "\nValidator: %05d", data_block.validator);
+        }
+        break;
+    }
+    case 0xE6:
+    case 0x1C6: {
+        parse_layout_E6(&data_block, block);
+        //number
+        furi_string_cat_printf(result, "Number: %010lu\n", data_block.number);
+        //use_before_date
+        DateTime card_use_before_date_s = {0};
+        from_days_to_datetime(data_block.use_before_date, &card_use_before_date_s, 2019);
+        furi_string_cat_printf(
+            result,
+            "Use before: %02d.%02d.%04d\n",
+            card_use_before_date_s.day,
+            card_use_before_date_s.month,
+            card_use_before_date_s.year);
+        //remaining_trips
+        furi_string_cat_printf(result, "Trips left: %d\n", data_block.remaining_trips);
+        //valid_from_date
+        DateTime card_use_from_date_s = {0};
+        from_minutes_to_datetime(data_block.valid_from_date, &card_use_from_date_s, 2019);
+        furi_string_cat_printf(
+            result,
+            "Valid from: %02d.%02d.%04d\n",
+            card_use_from_date_s.day,
+            card_use_from_date_s.month,
+            card_use_from_date_s.year);
+        //valid_to_date
+        DateTime card_use_to_date_s = {0};
+        from_minutes_to_datetime(
+            data_block.valid_from_date + data_block.valid_for_minutes - 1,
+            &card_use_to_date_s,
+            2019);
+        furi_string_cat_printf(
+            result,
+            "Valid to: %02d.%02d.%04d",
+            card_use_to_date_s.day,
+            card_use_to_date_s.month,
+            card_use_to_date_s.year);
+        //start_trip_minutes
+        if(data_block.start_trip_neg_minutes) {
+            DateTime card_start_trip_minutes_s = {0};
+            from_minutes_to_datetime(
+                data_block.valid_from_date + data_block.valid_for_minutes -
+                    data_block.start_trip_neg_minutes,
+                &card_start_trip_minutes_s,
+                2019); //-time
+            furi_string_cat_printf(
+                result,
+                "\nTrip from: %02d.%02d.%04d %02d:%02d",
+                card_start_trip_minutes_s.day,
+                card_start_trip_minutes_s.month,
+                card_start_trip_minutes_s.year,
+                card_start_trip_minutes_s.hour,
+                card_start_trip_minutes_s.minute);
+        }
+        //start_trip_m_minutes
+        if(data_block.metro_ride_with) {
+            DateTime card_start_trip_m_minutes_s = {0};
+            from_minutes_to_datetime(
+                data_block.valid_from_date + data_block.valid_for_minutes -
+                    data_block.start_trip_neg_minutes + data_block.metro_ride_with,
+                &card_start_trip_m_minutes_s,
+                2019);
+            furi_string_cat_printf(
+                result,
+                "\n(M) from: %02d.%02d.%04d %02d:%02d",
+                card_start_trip_m_minutes_s.day,
+                card_start_trip_m_minutes_s.month,
+                card_start_trip_m_minutes_s.year,
+                card_start_trip_m_minutes_s.hour,
+                card_start_trip_m_minutes_s.minute);
+        }
+        //transport
+        //validator
+        if(data_block.validator) {
+            furi_string_cat_printf(result, "\nValidator: %05d", data_block.validator);
+        }
+        break;
+    }
+    case 0x3CCB: {
+        parse_layout_FCB(&data_block, block);
+        //number
+        furi_string_cat_printf(result, "Number: %010lu\n", data_block.number);
+        //valid_from_date
+        DateTime card_use_from_date_s = {0};
+        from_days_to_datetime(data_block.valid_from_date, &card_use_from_date_s, 1992);
+        furi_string_cat_printf(
+            result,
+            "Valid from: %02d.%02d.%04d\n",
+            card_use_from_date_s.day,
+            card_use_from_date_s.month,
+            card_use_from_date_s.year);
+        //valid_to_date
+        DateTime card_use_to_date_s = {0};
+        from_days_to_datetime(data_block.valid_to_date, &card_use_to_date_s, 1992);
+        furi_string_cat_printf(
+            result,
+            "Valid to: %02d.%02d.%04d",
+            card_use_to_date_s.day,
+            card_use_to_date_s.month,
+            card_use_to_date_s.year);
+        break;
+    }
+    case 0x3C0B: {
+        //number
+        furi_string_cat_printf(result, "Number: %010lu\n", data_block.number);
+        //valid_from_date
+        DateTime card_use_from_date_s = {0};
+        from_days_to_datetime(data_block.valid_from_date, &card_use_from_date_s, 1992);
+        furi_string_cat_printf(
+            result,
+            "Valid from: %02d.%02d.%04d\n",
+            card_use_from_date_s.day,
+            card_use_from_date_s.month,
+            card_use_from_date_s.year);
+        //valid_to_date
+        DateTime card_use_to_date_s = {0};
+        from_days_to_datetime(data_block.valid_to_date, &card_use_to_date_s, 1992);
+        furi_string_cat_printf(
+            result,
+            "Valid to: %02d.%02d.%04d",
+            card_use_to_date_s.day,
+            card_use_to_date_s.month,
+            card_use_to_date_s.year);
+        break;
+    }
+    default:
+        result = NULL;
+        return false;
+    }
+
+    return true;
+}

+ 22 - 0
api/mosgortrans/mosgortrans_util.h

@@ -0,0 +1,22 @@
+#pragma once
+
+#include <bit_lib.h>
+#include <datetime.h>
+#include <furi/core/string.h>
+#include <nfc/protocols/mf_classic/mf_classic.h>
+#include <furi_hal_rtc.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+void render_section_header(
+    FuriString* str,
+    const char* name,
+    uint8_t prefix_separator_cnt,
+    uint8_t suffix_separator_cnt);
+bool mosgortrans_parse_transport_block(const MfClassicBlock* block, FuriString* result);
+
+#ifdef __cplusplus
+}
+#endif

+ 28 - 13
app/README.md

@@ -12,21 +12,36 @@ This is a list of metro cards and transit systems that are supported.
 - **Rav-Kav**  
 - **Rav-Kav**  
   - Status: Partially supported
   - Status: Partially supported
 - **Navigo**  
 - **Navigo**  
-  - Status: Fully supported
+  - Status: Fully supported.
 - **Charliecard**  
 - **Charliecard**  
-  - Status: Fully supported
+  - Status: Fully supported.
 - **Metromoney**  
 - **Metromoney**  
-  - Status: Fully supported
+  - Status: Fully supported.
 - **Bip!**  
 - **Bip!**  
-  - Status: Fully supported
+  - Status: Fully supported.
+- **Clipper**  
+  - Status: Fully supported.
+- **Troika**  
+  - Status: Fully supported.
+- **Myki**  
+  - Status: Fully supported.
+- **Opal**  
+  - Status: Fully supported.
+- **ITSO**
+  - Status: Fully supported.
 
 
-More coming soon!
+More coming soon! 
 
 
-## Credits
-- App Author: luu176
-- Charliecard Parser: zacharyweiss
-- Rav-Kav Parser: luu176
-- Navigo Parser: luu176
-- Metromoney Parser: Leptopt1los
-- Bip! Parser: rbasoalto, gornekich
-- Info Slave: 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
+- **ITSO parser**: gsp8181, hedger, gornekich
+- **Info Slaves**: Equip, TheDingo8MyBaby

+ 9 - 0
metroflip.c

@@ -126,6 +126,15 @@ void metroflip_app_blink_stop(Metroflip* metroflip) {
     notification_message(metroflip->notifications, &metroflip_app_sequence_blink_stop);
     notification_message(metroflip->notifications, &metroflip_app_sequence_blink_stop);
 }
 }
 
 
+void metroflip_exit_widget_callback(GuiButtonType result, InputType type, void* context) {
+    Metroflip* app = context;
+    UNUSED(result);
+
+    if(type == InputTypeShort) {
+        scene_manager_search_and_switch_to_previous_scene(app->scene_manager, MetroflipSceneStart);
+    }
+}
+
 // Calypso
 // Calypso
 
 
 void byte_to_binary(uint8_t byte, char* bits) {
 void byte_to_binary(uint8_t byte, char* bits) {

+ 2 - 0
metroflip_i.h

@@ -122,6 +122,8 @@ void metroflip_app_blink_stop(Metroflip* metroflip);
     if(!(locked)) submenu_add_item(submenu, label, index, callback, callback_context)
     if(!(locked)) submenu_add_item(submenu, label, index, callback, callback_context)
 #endif
 #endif
 
 
+void metroflip_exit_widget_callback(GuiButtonType result, InputType type, void* context);
+
 ///////////////////////////////// Calypso /////////////////////////////////
 ///////////////////////////////// Calypso /////////////////////////////////
 
 
 #define Metroflip_POLLER_MAX_BUFFER_SIZE 1024
 #define Metroflip_POLLER_MAX_BUFFER_SIZE 1024

+ 1 - 10
scenes/metroflip_scene_about.c

@@ -3,15 +3,6 @@
 
 
 #define TAG "Metroflip:Scene:About"
 #define TAG "Metroflip:Scene:About"
 
 
-void metroflip_about_widget_callback(GuiButtonType result, InputType type, void* context) {
-    Metroflip* app = context;
-    UNUSED(result);
-
-    if(type == InputTypeShort) {
-        scene_manager_search_and_switch_to_previous_scene(app->scene_manager, MetroflipSceneStart);
-    }
-}
-
 void metroflip_scene_about_on_enter(void* context) {
 void metroflip_scene_about_on_enter(void* context) {
     Metroflip* app = context;
     Metroflip* app = context;
     Widget* widget = app->widget;
     Widget* widget = app->widget;
@@ -29,7 +20,7 @@ void metroflip_scene_about_on_enter(void* context) {
     widget_add_text_scroll_element(widget, 0, 0, 128, 64, furi_string_get_cstr(str));
     widget_add_text_scroll_element(widget, 0, 0, 128, 64, furi_string_get_cstr(str));
 
 
     widget_add_button_element(
     widget_add_button_element(
-        widget, GuiButtonTypeRight, "Exit", metroflip_about_widget_callback, app);
+        widget, GuiButtonTypeRight, "Exit", metroflip_exit_widget_callback, app);
 
 
     furi_string_free(str);
     furi_string_free(str);
     view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewWidget);
     view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewWidget);

+ 1 - 10
scenes/metroflip_scene_bip.c

@@ -272,15 +272,6 @@ static bool
     return parsed;
     return parsed;
 }
 }
 
 
-void metroflip_bip_widget_callback(GuiButtonType result, InputType type, void* context) {
-    Metroflip* app = context;
-    UNUSED(result);
-
-    if(type == InputTypeShort) {
-        scene_manager_search_and_switch_to_previous_scene(app->scene_manager, MetroflipSceneStart);
-    }
-}
-
 static NfcCommand metroflip_scene_bip_poller_callback(NfcGenericEvent event, void* context) {
 static NfcCommand metroflip_scene_bip_poller_callback(NfcGenericEvent event, void* context) {
     furi_assert(context);
     furi_assert(context);
     furi_assert(event.event_data);
     furi_assert(event.event_data);
@@ -332,7 +323,7 @@ static NfcCommand metroflip_scene_bip_poller_callback(NfcGenericEvent event, voi
         widget_add_text_scroll_element(widget, 0, 0, 128, 64, furi_string_get_cstr(parsed_data));
         widget_add_text_scroll_element(widget, 0, 0, 128, 64, furi_string_get_cstr(parsed_data));
 
 
         widget_add_button_element(
         widget_add_button_element(
-            widget, GuiButtonTypeRight, "Exit", metroflip_bip_widget_callback, app);
+            widget, GuiButtonTypeRight, "Exit", metroflip_exit_widget_callback, app);
 
 
         furi_string_free(parsed_data);
         furi_string_free(parsed_data);
         view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewWidget);
         view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewWidget);

+ 1 - 10
scenes/metroflip_scene_charliecard.c

@@ -1183,15 +1183,6 @@ static bool charliecard_parse(FuriString* parsed_data, const MfClassicData* data
     return parsed;
     return parsed;
 }
 }
 
 
-void metroflip_charliecard_widget_callback(GuiButtonType result, InputType type, void* context) {
-    Metroflip* app = context;
-    UNUSED(result);
-
-    if(type == InputTypeShort) {
-        scene_manager_search_and_switch_to_previous_scene(app->scene_manager, MetroflipSceneStart);
-    }
-}
-
 static NfcCommand
 static NfcCommand
     metroflip_scene_charlicard_poller_callback(NfcGenericEvent event, void* context) {
     metroflip_scene_charlicard_poller_callback(NfcGenericEvent event, void* context) {
     furi_assert(context);
     furi_assert(context);
@@ -1243,7 +1234,7 @@ static NfcCommand
         widget_add_text_scroll_element(widget, 0, 0, 128, 64, furi_string_get_cstr(parsed_data));
         widget_add_text_scroll_element(widget, 0, 0, 128, 64, furi_string_get_cstr(parsed_data));
 
 
         widget_add_button_element(
         widget_add_button_element(
-            widget, GuiButtonTypeRight, "Exit", metroflip_charliecard_widget_callback, app);
+            widget, GuiButtonTypeRight, "Exit", metroflip_exit_widget_callback, app);
 
 
         furi_string_free(parsed_data);
         furi_string_free(parsed_data);
         view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewWidget);
         view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewWidget);

+ 659 - 0
scenes/metroflip_scene_clipper.c

@@ -0,0 +1,659 @@
+/*
+ * clipper.c - Parser for Clipper cards (San Francisco, California).
+ *
+ * Based on research, some of which dates to 2007!
+ *
+ * Copyright 2024 Jeremy Cooper <jeremy.gthb@baymoo.org>
+ *
+ * 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_desfire/mf_desfire_poller.h>
+
+#include <lib/nfc/protocols/mf_desfire/mf_desfire.h>
+
+#include <bit_lib.h>
+#include <datetime.h>
+#include <locale/locale.h>
+#include <inttypes.h>
+
+#define TAG "Metroflip:Scene:Clipper"
+
+//
+// Table of application ids observed in the wild, and their sources.
+//
+static const struct {
+    const MfDesfireApplicationId app;
+    const char* type;
+} clipper_types[] = {
+    // Application advertised on classic, plastic cards.
+    {.app = {.data = {0x90, 0x11, 0xf2}}, .type = "Card"},
+    // Application advertised on a mobile device.
+    {.app = {.data = {0x91, 0x11, 0xf2}}, .type = "Mobile Device"},
+};
+static const size_t kNumCardTypes = sizeof(clipper_types) / sizeof(clipper_types[0]);
+
+struct IdMapping_struct {
+    uint16_t id;
+    const char* name;
+};
+typedef struct IdMapping_struct IdMapping;
+
+#define COUNT(_array) sizeof(_array) / sizeof(_array[0])
+
+//
+// Known transportation agencies and their identifiers.
+//
+static const IdMapping agency_names[] = {
+    {.id = 0x0001, .name = "AC Transit"},
+    {.id = 0x0004, .name = "BART"},
+    {.id = 0x0006, .name = "Caltrain"},
+    {.id = 0x0008, .name = "CCTA"},
+    {.id = 0x000b, .name = "GGT"},
+    {.id = 0x000f, .name = "SamTrans"},
+    {.id = 0x0011, .name = "VTA"},
+    {.id = 0x0012, .name = "Muni"},
+    {.id = 0x0019, .name = "GG Ferry"},
+    {.id = 0x001b, .name = "SF Bay Ferry"},
+};
+static const size_t kNumAgencies = COUNT(agency_names);
+
+//
+// Known station names for various agencies.
+//
+static const IdMapping bart_zones[] = {
+    {.id = 0x0001, .name = "Colma"},
+    {.id = 0x0002, .name = "Daly City"},
+    {.id = 0x0003, .name = "Balboa Park"},
+    {.id = 0x0004, .name = "Glen Park"},
+    {.id = 0x0005, .name = "24th St Mission"},
+    {.id = 0x0006, .name = "16th St Mission"},
+    {.id = 0x0007, .name = "Civic Center/UN Plaza"},
+    {.id = 0x0008, .name = "Powell St"},
+    {.id = 0x0009, .name = "Montgomery St"},
+    {.id = 0x000a, .name = "Embarcadero"},
+    {.id = 0x000b, .name = "West Oakland"},
+    {.id = 0x000c, .name = "12th St/Oakland City Center"},
+    {.id = 0x000d, .name = "19th St/Oakland"},
+    {.id = 0x000e, .name = "MacArthur"},
+    {.id = 0x000f, .name = "Rockridge"},
+    {.id = 0x0010, .name = "Orinda"},
+    {.id = 0x0011, .name = "Lafayette"},
+    {.id = 0x0012, .name = "Walnut Creek"},
+    {.id = 0x0013, .name = "Pleasant Hill/Contra Costa Centre"},
+    {.id = 0x0014, .name = "Concord"},
+    {.id = 0x0015, .name = "North Concord/Martinez"},
+    {.id = 0x0016, .name = "Pittsburg/Bay Point"},
+    {.id = 0x0017, .name = "Ashby"},
+    {.id = 0x0018, .name = "Downtown Berkeley"},
+    {.id = 0x0019, .name = "North Berkeley"},
+    {.id = 0x001a, .name = "El Cerrito Plaza"},
+    {.id = 0x001b, .name = "El Cerrito Del Norte"},
+    {.id = 0x001c, .name = "Richmond"},
+    {.id = 0x001d, .name = "Lake Merrit"},
+    {.id = 0x001e, .name = "Fruitvale"},
+    {.id = 0x001f, .name = "Coliseum"},
+    {.id = 0x0021, .name = "San Leandro"},
+    {.id = 0x0022, .name = "Hayward"},
+    {.id = 0x0023, .name = "South Hayward"},
+    {.id = 0x0024, .name = "Union City"},
+    {.id = 0x0025, .name = "Fremont"},
+    {.id = 0x0026, .name = "Castro Valley"},
+    {.id = 0x0027, .name = "Dublin/Pleasanton"},
+    {.id = 0x0028, .name = "South San Francisco"},
+    {.id = 0x0029, .name = "San Bruno"},
+    {.id = 0x002a, .name = "SFO Airport"},
+    {.id = 0x002b, .name = "Millbrae"},
+    {.id = 0x002c, .name = "West Dublin/Pleasanton"},
+    {.id = 0x002d, .name = "OAK Airport"},
+    {.id = 0x002e, .name = "Warm Springs/South Fremont"},
+    {.id = 0x002f, .name = "Milpitas"},
+    {.id = 0x0030, .name = "Berryessa/North San Jose"},
+};
+static const size_t kNumBARTZones = COUNT(bart_zones);
+
+static const IdMapping muni_zones[] = {
+    {.id = 0x0000, .name = "City Street"},
+    {.id = 0x0005, .name = "Embarcadero"},
+    {.id = 0x0006, .name = "Montgomery"},
+    {.id = 0x0007, .name = "Powell"},
+    {.id = 0x0008, .name = "Civic Center"},
+    {.id = 0x0009, .name = "Van Ness"}, // Guessed
+    {.id = 0x000a, .name = "Church"},
+    {.id = 0x000b, .name = "Castro"},
+    {.id = 0x000c, .name = "Forest Hill"}, // Guessed
+    {.id = 0x000d, .name = "West Portal"},
+};
+static const size_t kNumMUNIZones = COUNT(muni_zones);
+
+static const IdMapping actransit_zones[] = {
+    {.id = 0x0000, .name = "City Street"},
+};
+static const size_t kNumACTransitZones = COUNT(actransit_zones);
+
+// Instead of persisting individual Station IDs, Caltrain saves Zone numbers.
+// https://www.caltrain.com/stations-zones
+static const IdMapping caltrain_zones[] = {
+    {.id = 0x0001, .name = "Zone 1"},
+    {.id = 0x0002, .name = "Zone 2"},
+    {.id = 0x0003, .name = "Zone 3"},
+    {.id = 0x0004, .name = "Zone 4"},
+    {.id = 0x0005, .name = "Zone 5"},
+    {.id = 0x0006, .name = "Zone 6"},
+};
+
+static const size_t kNumCaltrainZones = COUNT(caltrain_zones);
+
+//
+// Full agency+zone mapping.
+//
+static const struct {
+    uint16_t agency_id;
+    const IdMapping* zone_map;
+    size_t zone_count;
+} agency_zone_map[] = {
+    {.agency_id = 0x0001, .zone_map = actransit_zones, .zone_count = kNumACTransitZones},
+    {.agency_id = 0x0004, .zone_map = bart_zones, .zone_count = kNumBARTZones},
+    {.agency_id = 0x0006, .zone_map = caltrain_zones, .zone_count = kNumCaltrainZones},
+    {.agency_id = 0x0012, .zone_map = muni_zones, .zone_count = kNumMUNIZones}};
+static const size_t kNumAgencyZoneMaps = COUNT(agency_zone_map);
+
+// File ids of important files on the card.
+static const MfDesfireFileId clipper_ecash_file_id = 2;
+static const MfDesfireFileId clipper_histidx_file_id = 6;
+static const MfDesfireFileId clipper_identity_file_id = 8;
+static const MfDesfireFileId clipper_history_file_id = 14;
+
+struct ClipperCardInfo_struct {
+    uint32_t serial_number;
+    uint16_t counter;
+    uint16_t last_txn_id;
+    uint32_t last_updated_tm_1900;
+    uint16_t last_terminal_id;
+    int16_t balance_cents;
+};
+typedef struct ClipperCardInfo_struct ClipperCardInfo;
+
+// Forward declarations for helper functions.
+static void furi_string_cat_timestamp(
+    FuriString* str,
+    const char* date_hdr,
+    const char* time_hdr,
+    uint32_t tmst_1900);
+static bool get_file_contents(
+    const MfDesfireApplication* app,
+    const MfDesfireFileId* id,
+    MfDesfireFileType type,
+    size_t min_size,
+    const uint8_t** out);
+static bool decode_id_file(const uint8_t* ef8_data, ClipperCardInfo* info);
+static bool decode_cash_file(const uint8_t* ef2_data, ClipperCardInfo* info);
+static bool get_map_item(uint16_t id, const IdMapping* map, size_t sz, const char** out);
+static bool get_agency_zone_name(uint16_t agency_id, uint16_t zone_id, const char** out);
+static void
+    decode_usd(int16_t amount_cents, bool* out_is_negative, int16_t* out_usd, uint16_t* out_cents);
+static bool dump_ride_history(
+    const uint8_t* index_file,
+    const uint8_t* history_file,
+    size_t len,
+    FuriString* parsed_data);
+static bool dump_ride_event(const uint8_t* record, FuriString* parsed_data);
+
+// Unmarshal a 32-bit integer, big endian, unsigned
+static inline uint32_t get_u32be(const uint8_t* field) {
+    return bit_lib_bytes_to_num_be(field, 4);
+}
+
+// Unmarshal a 16-bit integer, big endian, unsigned
+static uint16_t get_u16be(const uint8_t* field) {
+    return bit_lib_bytes_to_num_be(field, 2);
+}
+
+// Unmarshal a 16-bit integer, big endian, signed, two's-complement
+static int16_t get_i16be(const uint8_t* field) {
+    uint16_t raw = get_u16be(field);
+    if(raw > 0x7fff)
+        return -((uint32_t)0x10000 - raw);
+    else
+        return raw;
+}
+
+static bool clipper_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 = NULL;
+        const char* device_description = NULL;
+
+        for(size_t i = 0; i < kNumCardTypes; i++) {
+            app = mf_desfire_get_application(data, &clipper_types[i].app);
+            device_description = clipper_types[i].type;
+            if(app != NULL) break;
+        }
+        // If no matching application was found, abort this parser.
+        if(app == NULL) break;
+        ClipperCardInfo info;
+        const uint8_t* id_data;
+        if(!get_file_contents(
+               app, &clipper_identity_file_id, MfDesfireFileTypeStandard, 5, &id_data))
+            break;
+        if(!decode_id_file(id_data, &info)) break;
+        const uint8_t* cash_data;
+        if(!get_file_contents(app, &clipper_ecash_file_id, MfDesfireFileTypeBackup, 32, &cash_data))
+            break;
+        if(!decode_cash_file(cash_data, &info)) break;
+        int16_t balance_usd;
+        uint16_t balance_cents;
+        bool _balance_is_negative;
+        decode_usd(info.balance_cents, &_balance_is_negative, &balance_usd, &balance_cents);
+        furi_string_cat_printf(
+            parsed_data,
+            "\e#Clipper\n"
+            "Serial: %" PRIu32 "\n"
+            "Balance: $%d.%02u\n"
+            "Type: %s\n"
+            "\e#Last Update\n",
+            info.serial_number,
+            balance_usd,
+            balance_cents,
+            device_description);
+        if(info.last_updated_tm_1900 != 0)
+            furi_string_cat_timestamp(
+                parsed_data, "Date: ", "\nTime: ", info.last_updated_tm_1900);
+        else
+            furi_string_cat_str(parsed_data, "Never");
+        furi_string_cat_printf(
+            parsed_data,
+            "\nTerminal: 0x%04x\n"
+            "Transaction Id: %u\n"
+            "Counter: %u\n",
+            info.last_terminal_id,
+            info.last_txn_id,
+            info.counter);
+
+        const uint8_t *history_index, *history;
+
+        if(!get_file_contents(
+               app, &clipper_histidx_file_id, MfDesfireFileTypeBackup, 16, &history_index))
+            break;
+        if(!get_file_contents(
+               app, &clipper_history_file_id, MfDesfireFileTypeStandard, 512, &history))
+            break;
+
+        if(!dump_ride_history(history_index, history, 512, parsed_data)) break;
+
+        parsed = true;
+    } while(false);
+
+    return parsed;
+}
+
+static bool get_file_contents(
+    const MfDesfireApplication* app,
+    const MfDesfireFileId* id,
+    MfDesfireFileType type,
+    size_t min_size,
+    const uint8_t** out) {
+    const MfDesfireFileSettings* settings = mf_desfire_get_file_settings(app, id);
+    if(settings == NULL) return false;
+    if(settings->type != type) return false;
+
+    const MfDesfireFileData* file_data = mf_desfire_get_file_data(app, id);
+
+    if(file_data == NULL) return false;
+
+    if(simple_array_get_count(file_data->data) < min_size) return false;
+
+    *out = simple_array_cget_data(file_data->data);
+
+    return true;
+}
+
+static bool decode_id_file(const uint8_t* ef8_data, ClipperCardInfo* info) {
+    // Identity file (8)
+    //
+    // Byte view
+    //
+    //       0    1    2    3    4    5    6    7    8
+    //       +----+----.----.----.----+----.----.----+
+    // 0x00  | uk | card_id           | unknown      |
+    //       +----+----.----.----.----+----.----.----+
+    // 0x08  | unknown                               |
+    //       +----.----.----.----.----.----.----.----+
+    // 0x10    ...
+    //
+    //
+    // Field          Datatype   Description
+    // -----          --------   -----------
+    // uk             ?8??       Unknown, 8-bit byte
+    // card_id        U32BE      Card identifier
+    //
+    info->serial_number = bit_lib_bytes_to_num_be(&ef8_data[1], 4);
+    return true;
+}
+
+static bool decode_cash_file(const uint8_t* ef2_data, ClipperCardInfo* info) {
+    // ECash file (2)
+    //
+    // Byte view
+    //
+    //       0    1    2    3    4    5    6    7    8
+    //       +----.----+----.----+----.----.----.----+
+    // 0x00  |  unk00  | counter | timestamp_1900    |
+    //       +----.----+----.----+----.----.----.----+
+    // 0x08  | term_id |     unk01                   |
+    //       +----.----+----.----+----.----.----.----+
+    // 0x10  | txn_id  | balance |      unknown      |
+    //       +----.----+----.----+----.----.----.----+
+    // 0x18  |               unknown                 |
+    //       +---------------------------------------+
+    //
+    // Field          Datatype Description
+    // -----          -------- -----------
+    // unk00          U8[2]     Unknown bytes
+    // counter        U16BE     Unknown, appears to be a counter
+    // timestamp_1900 U32BE     Timestamp of last transaction, in seconds
+    //                          since 1900-01-01 GMT.
+    // unk01          U8[6]     Unknown bytes
+    // txn_id         U16BE     Id of last transaction.
+    // balance        S16BE     Card cash balance, in cents.
+    //                          Cards can obtain negative balances in this
+    //                          system, so balances are signed integers.
+    //                          Maximum card balance is therefore
+    //                          $327.67.
+    // unk02          U8[12]    Unknown bytes.
+    //
+    info->counter = get_u16be(&ef2_data[2]);
+    info->last_updated_tm_1900 = get_u32be(&ef2_data[4]);
+    info->last_terminal_id = get_u16be(&ef2_data[8]);
+    info->last_txn_id = get_u16be(&ef2_data[0x10]);
+    info->balance_cents = get_i16be(&ef2_data[0x12]);
+    return true;
+}
+
+static bool dump_ride_history(
+    const uint8_t* index_file,
+    const uint8_t* history_file,
+    size_t len,
+    FuriString* parsed_data) {
+    static const size_t kRideRecordSize = 0x20;
+
+    for(size_t i = 0; i < 16; i++) {
+        uint8_t record_num = index_file[i];
+        if(record_num == 0xff) break;
+
+        size_t record_offset = record_num * kRideRecordSize;
+
+        if(record_offset + kRideRecordSize > len) break;
+
+        const uint8_t* record = &history_file[record_offset];
+        if(!dump_ride_event(record, parsed_data)) break;
+    }
+
+    return true;
+}
+
+static bool dump_ride_event(const uint8_t* record, FuriString* parsed_data) {
+    // Ride record
+    //
+    //       0    1    2    3    4    5    6    7    8
+    //       +----+----+----.----+----.----+----.----+
+    // 0x00  |0x10| ?  | agency  | ?       | fare    |
+    //       +----.----+----.----+----.----.----.----+
+    // 0x08  | ?       | vehicle | time_on           |
+    //       +----.----.----.----+----.----+----.----+
+    // 0x10  | time_off          | zone_on | zone_off|
+    //       +----+----.----.----.----+----+----+----+
+    // 0x18  | ?  | ?                 | ?  | ?  | ?  |
+    //       +----+----.----.----.----+----+----+----+
+    //
+    // Field          Datatype Description
+    // -----          -------- -----------
+    // agency         U16BE    Transportation agency identifier.
+    //                         Known ids:
+    //                         1  == AC Transit
+    //                         4  == BART
+    //                         18 == SF MUNI
+    // fare           I16BE    Fare deducted, in cents.
+    // vehicle        U16BE    Vehicle id (0 == not provided)
+    // time_on        U32BE    Boarding time, in seconds since 1900-01-01 GMT.
+    // time_off       U32BE    Off-boarding time, if present, in seconds
+    //                         since 1900-01-01 GMT. Set to zero if no offboard
+    //                         has been recorded.
+    // zone_on        U16BE    Id of boarding zone or station. Agency-specific.
+    // zone_off       U16BE    Id of offboarding zone or station. Agency-
+    //                         specific.
+    if(record[0] != 0x10) return false;
+
+    uint16_t agency_id = get_u16be(&record[2]);
+    if(agency_id == 0)
+        // Likely empty record. Skip.
+        return false;
+    const char* agency_name;
+    bool ok = get_map_item(agency_id, agency_names, kNumAgencies, &agency_name);
+    if(!ok) agency_name = "Unknown";
+
+    uint16_t vehicle_id = get_u16be(&record[0x0a]);
+
+    int16_t fare_raw_cents = get_i16be(&record[6]);
+    bool _fare_is_negative;
+    int16_t fare_usd;
+    uint16_t fare_cents;
+    decode_usd(fare_raw_cents, &_fare_is_negative, &fare_usd, &fare_cents);
+
+    uint32_t time_on_raw = get_u32be(&record[0x0c]);
+    uint32_t time_off_raw = get_u32be(&record[0x10]);
+    uint16_t zone_id_on = get_u16be(&record[0x14]);
+    uint16_t zone_id_off = get_u16be(&record[0x16]);
+
+    const char *zone_on, *zone_off;
+    if(!get_agency_zone_name(agency_id, zone_id_on, &zone_on)) {
+        zone_on = "Unknown";
+    }
+    if(!get_agency_zone_name(agency_id, zone_id_off, &zone_off)) {
+        zone_off = "Unknown";
+    }
+
+    furi_string_cat_str(parsed_data, "\e#Ride Record\n");
+    furi_string_cat_timestamp(parsed_data, "Date: ", "\nTime: ", time_on_raw);
+    furi_string_cat_printf(
+        parsed_data,
+        "\n"
+        "Fare: $%d.%02u\n"
+        "Agency: %s (%04x)\n"
+        "On: %s (%04x)\n",
+        fare_usd,
+        fare_cents,
+        agency_name,
+        agency_id,
+        zone_on,
+        zone_id_on);
+    if(vehicle_id != 0) {
+        furi_string_cat_printf(parsed_data, "Vehicle id: %d\n", vehicle_id);
+    }
+    if(time_off_raw != 0) {
+        furi_string_cat_printf(parsed_data, "Off: %s (%04x)\n", zone_off, zone_id_off);
+        furi_string_cat_timestamp(parsed_data, "Date Off: ", "\nTime Off: ", time_off_raw);
+        furi_string_cat_str(parsed_data, "\n");
+    }
+
+    return true;
+}
+
+static bool get_map_item(uint16_t id, const IdMapping* map, size_t sz, const char** out) {
+    for(size_t i = 0; i < sz; i++) {
+        if(map[i].id == id) {
+            *out = map[i].name;
+            return true;
+        }
+    }
+
+    return false;
+}
+
+static bool get_agency_zone_name(uint16_t agency_id, uint16_t zone_id, const char** out) {
+    for(size_t i = 0; i < kNumAgencyZoneMaps; i++) {
+        if(agency_zone_map[i].agency_id == agency_id) {
+            return get_map_item(
+                zone_id, agency_zone_map[i].zone_map, agency_zone_map[i].zone_count, out);
+        }
+    }
+
+    return false;
+}
+
+// Split a balance/fare amount from raw cents to dollars and cents portion,
+// automatically adjusting the cents portion so that it is always positive,
+// for easier display.
+static void
+    decode_usd(int16_t amount_cents, bool* out_is_negative, int16_t* out_usd, uint16_t* out_cents) {
+    *out_usd = amount_cents / 100;
+
+    if(amount_cents >= 0) {
+        *out_is_negative = false;
+        *out_cents = amount_cents % 100;
+    } else {
+        *out_is_negative = true;
+        *out_cents = (amount_cents * -1) % 100;
+    }
+}
+
+// Decode a raw 1900-based timestamp and append a human-readable form to a
+// FuriString.
+static void furi_string_cat_timestamp(
+    FuriString* str,
+    const char* date_hdr,
+    const char* time_hdr,
+    uint32_t tmst_1900) {
+    DateTime tm;
+
+    datetime_timestamp_to_datetime(tmst_1900, &tm);
+
+    FuriString* date_str = furi_string_alloc();
+    locale_format_date(date_str, &tm, locale_get_date_format(), "-");
+
+    FuriString* time_str = furi_string_alloc();
+    locale_format_time(time_str, &tm, locale_get_time_format(), true);
+
+    furi_string_cat_printf(
+        str,
+        "%s%s%s%s (UTC)",
+        date_hdr,
+        furi_string_get_cstr(date_str),
+        time_hdr,
+        furi_string_get_cstr(time_str));
+
+    furi_string_free(date_str);
+    furi_string_free(time_str);
+}
+
+static NfcCommand metroflip_scene_clipper_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(!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(
+            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_clipper_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_clipper_poller_callback, app);
+
+    metroflip_app_blink_start(app);
+}
+
+bool metroflip_scene_clipper_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_clipper_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);
+    }
+}

+ 5 - 0
scenes/metroflip_scene_config.h

@@ -2,8 +2,13 @@ ADD_SCENE(metroflip, start, Start)
 ADD_SCENE(metroflip, ravkav, RavKav)
 ADD_SCENE(metroflip, ravkav, RavKav)
 ADD_SCENE(metroflip, navigo, Navigo)
 ADD_SCENE(metroflip, navigo, Navigo)
 ADD_SCENE(metroflip, charliecard, CharlieCard)
 ADD_SCENE(metroflip, charliecard, CharlieCard)
+ADD_SCENE(metroflip, clipper, Clipper)
 ADD_SCENE(metroflip, metromoney, Metromoney)
 ADD_SCENE(metroflip, metromoney, Metromoney)
 ADD_SCENE(metroflip, read_success, ReadSuccess)
 ADD_SCENE(metroflip, read_success, ReadSuccess)
 ADD_SCENE(metroflip, bip, Bip)
 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, about, About)
 ADD_SCENE(metroflip, credits, Credits)
 ADD_SCENE(metroflip, credits, Credits)

+ 1 - 10
scenes/metroflip_scene_credits.c

@@ -3,15 +3,6 @@
 
 
 #define TAG "Metroflip:Scene:Credits"
 #define TAG "Metroflip:Scene:Credits"
 
 
-void metroflip_credits_widget_callback(GuiButtonType result, InputType type, void* context) {
-    Metroflip* app = context;
-    UNUSED(result);
-
-    if(type == InputTypeShort) {
-        scene_manager_search_and_switch_to_previous_scene(app->scene_manager, MetroflipSceneStart);
-    }
-}
-
 void metroflip_scene_credits_on_enter(void* context) {
 void metroflip_scene_credits_on_enter(void* context) {
     Metroflip* app = context;
     Metroflip* app = context;
     Widget* widget = app->widget;
     Widget* widget = app->widget;
@@ -34,7 +25,7 @@ void metroflip_scene_credits_on_enter(void* context) {
     widget_add_text_scroll_element(widget, 0, 0, 128, 64, furi_string_get_cstr(str));
     widget_add_text_scroll_element(widget, 0, 0, 128, 64, furi_string_get_cstr(str));
 
 
     widget_add_button_element(
     widget_add_button_element(
-        widget, GuiButtonTypeRight, "Exit", metroflip_credits_widget_callback, app);
+        widget, GuiButtonTypeRight, "Exit", metroflip_exit_widget_callback, app);
 
 
     furi_string_free(str);
     furi_string_free(str);
     view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewWidget);
     view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewWidget);

+ 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);
+    }
+}

+ 188 - 0
scenes/metroflip_scene_myki.c

@@ -0,0 +1,188 @@
+#include <flipper_application.h>
+
+#include <lib/nfc/protocols/mf_desfire/mf_desfire.h>
+#include <stdio.h>
+
+#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;
+
+static uint8_t myki_calculate_luhn(uint64_t number) {
+    // https://en.wikipedia.org/wiki/Luhn_algorithm
+    // Drop existing check digit to form payload
+    uint64_t payload = number / 10;
+    int sum = 0;
+    int position = 0;
+
+    while(payload > 0) {
+        int digit = payload % 10;
+        if(position % 2 == 0) {
+            digit *= 2;
+        }
+        if(digit > 9) {
+            digit = (digit / 10) + (digit % 10);
+        }
+        sum += digit;
+        payload /= 10;
+        position++;
+    }
+
+    return (10 - (sum % 10)) % 10;
+}
+
+static bool myki_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, &myki_app_id);
+        if(app == NULL) break;
+
+        typedef struct {
+            uint32_t top;
+            uint32_t bottom;
+        } MykiFile;
+
+        const MfDesfireFileSettings* file_settings =
+            mf_desfire_get_file_settings(app, &myki_file_id);
+
+        if(file_settings == NULL || file_settings->type != MfDesfireFileTypeStandard ||
+           file_settings->data.size < sizeof(MykiFile))
+            break;
+
+        const MfDesfireFileData* file_data = mf_desfire_get_file_data(app, &myki_file_id);
+        if(file_data == NULL) break;
+
+        const MykiFile* myki_file = simple_array_cget_data(file_data->data);
+
+        // All myki card numbers are prefixed with "308425"
+        if(myki_file->top != 308425UL) break;
+        // Card numbers are always 15 digits in length
+        if(myki_file->bottom < 10000000UL || myki_file->bottom >= 100000000UL) break;
+
+        uint64_t card_number = myki_file->top * 1000000000ULL + myki_file->bottom * 10UL;
+        // Stored card number doesn't include check digit
+        card_number += myki_calculate_luhn(card_number);
+
+        furi_string_set(parsed_data, "\e#myki\nNo.: ");
+
+        // Stylise card number according to the physical card
+        char card_string[20];
+        snprintf(card_string, sizeof(card_string), "%llu", card_number);
+
+        // Digit count in each space-separated group
+        static const uint8_t digit_count[] = {1, 5, 4, 4, 1};
+
+        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, card_string[j + k]);
+            }
+            furi_string_push_back(parsed_data, ' ');
+        }
+
+        parsed = true;
+    } while(false);
+
+    return parsed;
+}
+
+static NfcCommand metroflip_scene_myki_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(!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(
+            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_myki_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_myki_poller_callback, app);
+
+    metroflip_app_blink_start(app);
+}
+
+bool metroflip_scene_myki_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_myki_on_exit(void* context) {
+    Metroflip* app = context;
+    widget_reset(app->widget);
+
+    if(app->poller) {
+        nfc_poller_stop(app->poller);
+        nfc_poller_free(app->poller);
+    }
+}

+ 1 - 10
scenes/metroflip_scene_navigo.c

@@ -8,15 +8,6 @@
 
 
 #define TAG "Metroflip:Scene:Navigo"
 #define TAG "Metroflip:Scene:Navigo"
 
 
-void metroflip_navigo_widget_callback(GuiButtonType result, InputType type, void* context) {
-    Metroflip* app = context;
-    UNUSED(result);
-
-    if(type == InputTypeShort) {
-        scene_manager_search_and_switch_to_previous_scene(app->scene_manager, MetroflipSceneStart);
-    }
-}
-
 static NfcCommand metroflip_scene_navigo_poller_callback(NfcGenericEvent event, void* context) {
 static NfcCommand metroflip_scene_navigo_poller_callback(NfcGenericEvent event, void* context) {
     furi_assert(event.protocol == NfcProtocolIso14443_4b);
     furi_assert(event.protocol == NfcProtocolIso14443_4b);
     NfcCommand next_command = NfcCommandContinue;
     NfcCommand next_command = NfcCommandContinue;
@@ -310,7 +301,7 @@ static NfcCommand metroflip_scene_navigo_poller_callback(NfcGenericEvent event,
                     widget, 0, 0, 128, 64, furi_string_get_cstr(parsed_data));
                     widget, 0, 0, 128, 64, furi_string_get_cstr(parsed_data));
 
 
                 widget_add_button_element(
                 widget_add_button_element(
-                    widget, GuiButtonTypeRight, "Exit", metroflip_navigo_widget_callback, app);
+                    widget, GuiButtonTypeRight, "Exit", metroflip_exit_widget_callback, app);
 
 
                 furi_string_free(parsed_data);
                 furi_string_free(parsed_data);
                 view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewWidget);
                 view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewWidget);

+ 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);
+    }
+}

+ 1 - 10
scenes/metroflip_scene_ravkav.c

@@ -4,15 +4,6 @@
 
 
 #define TAG "Metroflip:Scene:RavKav"
 #define TAG "Metroflip:Scene:RavKav"
 
 
-void metroflip_ravkav_widget_callback(GuiButtonType result, InputType type, void* context) {
-    Metroflip* app = context;
-    UNUSED(result);
-
-    if(type == InputTypeShort) {
-        scene_manager_search_and_switch_to_previous_scene(app->scene_manager, MetroflipSceneStart);
-    }
-}
-
 static NfcCommand metroflip_scene_ravkav_poller_callback(NfcGenericEvent event, void* context) {
 static NfcCommand metroflip_scene_ravkav_poller_callback(NfcGenericEvent event, void* context) {
     furi_assert(event.protocol == NfcProtocolIso14443_4b);
     furi_assert(event.protocol == NfcProtocolIso14443_4b);
     NfcCommand next_command = NfcCommandContinue;
     NfcCommand next_command = NfcCommandContinue;
@@ -274,7 +265,7 @@ static NfcCommand metroflip_scene_ravkav_poller_callback(NfcGenericEvent event,
                     widget, 0, 0, 128, 64, furi_string_get_cstr(parsed_data));
                     widget, 0, 0, 128, 64, furi_string_get_cstr(parsed_data));
 
 
                 widget_add_button_element(
                 widget_add_button_element(
-                    widget, GuiButtonTypeRight, "Exit", metroflip_ravkav_widget_callback, app);
+                    widget, GuiButtonTypeRight, "Exit", metroflip_exit_widget_callback, app);
 
 
                 furi_string_free(parsed_data);
                 furi_string_free(parsed_data);
                 view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewWidget);
                 view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewWidget);

+ 1 - 10
scenes/metroflip_scene_read_success.c

@@ -3,15 +3,6 @@
 
 
 #define TAG "Metroflip:Scene:ReadSuccess"
 #define TAG "Metroflip:Scene:ReadSuccess"
 
 
-void metroflip_success_widget_callback(GuiButtonType result, InputType type, void* context) {
-    Metroflip* app = context;
-    UNUSED(result);
-
-    if(type == InputTypeShort) {
-        scene_manager_search_and_switch_to_previous_scene(app->scene_manager, MetroflipSceneStart);
-    }
-}
-
 void metroflip_scene_read_success_on_enter(void* context) {
 void metroflip_scene_read_success_on_enter(void* context) {
     Metroflip* app = context;
     Metroflip* app = context;
     Widget* widget = app->widget;
     Widget* widget = app->widget;
@@ -37,7 +28,7 @@ void metroflip_scene_read_success_on_enter(void* context) {
     widget_add_text_scroll_element(widget, 0, 0, 128, 64, furi_string_get_cstr(str));
     widget_add_text_scroll_element(widget, 0, 0, 128, 64, furi_string_get_cstr(str));
 
 
     widget_add_button_element(
     widget_add_button_element(
-        widget, GuiButtonTypeRight, "Exit", metroflip_success_widget_callback, app);
+        widget, GuiButtonTypeRight, "Exit", metroflip_exit_widget_callback, app);
 
 
     furi_string_free(str);
     furi_string_free(str);
     view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewWidget);
     view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewWidget);

+ 15 - 0
scenes/metroflip_scene_start.c

@@ -24,6 +24,21 @@ void metroflip_scene_start_on_enter(void* context) {
         metroflip_scene_start_submenu_callback,
         metroflip_scene_start_submenu_callback,
         app);
         app);
 
 
+    submenu_add_item(
+        submenu, "Clipper", MetroflipSceneClipper, metroflip_scene_start_submenu_callback, app);
+
+    submenu_add_item(
+        submenu, "Myki", MetroflipSceneMyki, metroflip_scene_start_submenu_callback, app);
+
+    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_add_item(
         submenu,
         submenu,
         "Metromoney",
         "Metromoney",

+ 311 - 0
scenes/metroflip_scene_troika.c

@@ -0,0 +1,311 @@
+#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 "../api/mosgortrans/mosgortrans_util.h"
+
+#include <dolphin/dolphin.h>
+#include <bit_lib.h>
+#include <furi_hal.h>
+#include <nfc/nfc.h>
+#include <nfc/nfc_device.h>
+#include <nfc/nfc_listener.h>
+
+#define TAG "Metroflip:Scene:Troika"
+
+typedef struct {
+    uint64_t a;
+    uint64_t b;
+} MfClassicKeyPair;
+
+typedef struct {
+    const MfClassicKeyPair* keys;
+    uint32_t data_sector;
+} TroikaCardConfig;
+
+static const MfClassicKeyPair troika_1k_keys[] = {
+    {.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},
+};
+
+static const MfClassicKeyPair troika_4k_keys[] = {
+    {.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;
+
+    if(type == MfClassicType1k) {
+        config->data_sector = 11;
+        config->keys = troika_1k_keys;
+    } else if(type == MfClassicType4k) {
+        config->data_sector = 8; // Further testing needed
+        config->keys = troika_4k_keys;
+    } else {
+        success = false;
+    }
+
+    return success;
+}
+
+static bool troika_parse(FuriString* parsed_data, const MfClassicData* data) {
+    bool parsed = false;
+
+    do {
+        // Verify card type
+        TroikaCardConfig cfg = {};
+        if(!troika_get_card_config(&cfg, data->type)) break;
+
+        // Verify key
+        const MfClassicSectorTrailer* sec_tr =
+            mf_classic_get_sector_trailer_by_sector(data, cfg.data_sector);
+
+        const uint64_t key =
+            bit_lib_bytes_to_num_be(sec_tr->key_a.data, COUNT_OF(sec_tr->key_a.data));
+        if(key != cfg.keys[cfg.data_sector].a) break;
+
+        FuriString* metro_result = furi_string_alloc();
+        FuriString* ground_result = furi_string_alloc();
+        FuriString* tat_result = furi_string_alloc();
+
+        bool is_metro_data_present =
+            mosgortrans_parse_transport_block(&data->block[32], metro_result);
+        bool is_ground_data_present =
+            mosgortrans_parse_transport_block(&data->block[28], ground_result);
+        bool is_tat_data_present = mosgortrans_parse_transport_block(&data->block[16], tat_result);
+
+        furi_string_cat_printf(parsed_data, "\e#Troyka card\n");
+        if(is_metro_data_present && !furi_string_empty(metro_result)) {
+            render_section_header(parsed_data, "Metro", 22, 21);
+            furi_string_cat_printf(parsed_data, "%s\n", furi_string_get_cstr(metro_result));
+        }
+
+        if(is_ground_data_present && !furi_string_empty(ground_result)) {
+            render_section_header(parsed_data, "Ediny", 22, 22);
+            furi_string_cat_printf(parsed_data, "%s\n", furi_string_get_cstr(ground_result));
+        }
+
+        if(is_tat_data_present && !furi_string_empty(tat_result)) {
+            render_section_header(parsed_data, "TAT", 24, 23);
+            furi_string_cat_printf(parsed_data, "%s\n", furi_string_get_cstr(tat_result));
+        }
+
+        furi_string_free(tat_result);
+        furi_string_free(ground_result);
+        furi_string_free(metro_result);
+
+        parsed = is_metro_data_present || is_ground_data_present || is_tat_data_present;
+    } while(false);
+
+    return parsed;
+}
+
+bool checked = false;
+
+static NfcCommand metroflip_scene_troika_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};
+        MfClassicKeyType key_type = MfClassicKeyTypeA;
+        bit_lib_num_to_bytes_be(troika_1k_keys[app->sec_num].a, COUNT_OF(key.data), key.data);
+        if(!checked) {
+            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;
+            app->sec_num++;
+            checked = true;
+        }
+        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);
+        if(mfc_data->type == MfClassicType1k) {
+            bit_lib_num_to_bytes_be(troika_1k_keys[app->sec_num].a, COUNT_OF(key.data), key.data);
+
+            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_data->type == MfClassicType4k) {
+            bit_lib_num_to_bytes_be(troika_4k_keys[app->sec_num].a, COUNT_OF(key.data), key.data);
+
+            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 == 40) {
+                mfc_event->data->read_sector_request_data.key_provided = false;
+                app->sec_num = 0;
+            }
+            app->sec_num++;
+        }
+    } else if(mfc_event->type == MfClassicPollerEventTypeSuccess) {
+        const MfClassicData* mfc_data = nfc_device_get_data(app->nfc_device, NfcProtocolMfClassic);
+        FuriString* parsed_data = furi_string_alloc();
+        Widget* widget = app->widget;
+        if(!troika_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);
+        metroflip_app_blink_stop(app);
+        command = NfcCommandStop;
+    } else if(mfc_event->type == MfClassicPollerEventTypeFail) {
+        FURI_LOG_I(TAG, "fail");
+        command = NfcCommandStop;
+    }
+
+    return command;
+}
+
+void metroflip_scene_troika_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);
+    nfc_scanner_alloc(app->nfc);
+    app->poller = nfc_poller_alloc(app->nfc, NfcProtocolMfClassic);
+    nfc_poller_start(app->poller, metroflip_scene_troika_poller_callback, app);
+
+    metroflip_app_blink_start(app);
+}
+
+bool metroflip_scene_troika_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.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);
+        consumed = true;
+    }
+
+    return consumed;
+}
+
+void metroflip_scene_troika_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);
+}