Merge pull request #981 from mxcdoam/parsers

NFC: Additional parsers
This commit is contained in:
MMX
2026-03-30 00:07:35 +03:00
committed by GitHub
9 changed files with 2240 additions and 242 deletions
+27
View File
@@ -305,6 +305,33 @@ App(
sources=["plugins/supported_cards/plantain.c"],
)
App(
appid="szppk_so_parser",
apptype=FlipperAppType.PLUGIN,
entry_point="szppk_so_plugin_ep",
targets=["f7"],
requires=["nfc"],
sources=["plugins/supported_cards/szppk_so.c"],
)
App(
appid="sk_tk_parser",
apptype=FlipperAppType.PLUGIN,
entry_point="sk_tk_plugin_ep",
targets=["f7"],
requires=["nfc"],
sources=["plugins/supported_cards/sk_tk.c"],
)
App(
appid="sev_tk_parser",
apptype=FlipperAppType.PLUGIN,
entry_point="sev_tk_plugin_ep",
targets=["f7"],
requires=["nfc"],
sources=["plugins/supported_cards/sevppk_tk.c"],
)
App(
appid="two_cities_parser",
apptype=FlipperAppType.PLUGIN,
@@ -1,23 +1,21 @@
//Based on parsers written by Leptoptilos and Assasinfil. Also, thanks to WillyJL (<me@willyjl.dev>) for help!
#include "nfc_supported_card_plugin.h"
#include <flipper_application/flipper_application.h>
#include <nfc/nfc_device.h>
#include <bit_lib/bit_lib.h>
#include <datetime.h>
#include <nfc/protocols/mf_classic/mf_classic_poller_sync.h>
#define TAG "Plantain"
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);
}
#include <flipper_format/flipper_format.h>
#define PLANTAIN_EPOCH_START 1262304000 //2010-01-01
#define PPK_WHOLE_EPOCH_START 946684800 //2000-01-01
#define PPK_CURRENT_EPOCH_START 1388534400 //2014-01-01
#define SECONDS_IN_A_DAY 86400
#define SECONDS_IN_A_MINUTE 60
#define FIRST_PPK_TICKET_OFFSET 101
#define FIRST_TICKET_VALUE_BLOCK 104
#define SECOND_PPK_TICKET_OFFSET 102
#define SECOND_TICKET_VALUE_BLOCK 108
typedef struct {
uint64_t a;
@@ -30,79 +28,549 @@ typedef struct {
} PlantainCardConfig;
static const MfClassicKeyPair plantain_1k_keys[] = {
{.a = 0xffffffffffff, .b = 0xffffffffffff},
{.a = 0xffffffffffff, .b = 0xffffffffffff},
{.a = 0xffffffffffff, .b = 0xffffffffffff},
{.a = 0xffffffffffff, .b = 0xffffffffffff},
{.a = 0xe56ac127dd45, .b = 0x19fc84a3784b},
{.a = 0x77dabc9825e1, .b = 0x9764fec3154a},
{.a = 0xffffffffffff, .b = 0xffffffffffff},
{.a = 0xffffffffffff, .b = 0xffffffffffff},
{.a = 0x26973ea74321, .b = 0xd27058c6e2c7},
{.a = 0xeb0a8ff88ade, .b = 0x578a9ada41e3},
{.a = 0xea0fd73cb149, .b = 0x29c35fa068fb},
{.a = 0xc76bf71a2509, .b = 0x9ba241db3f56},
{.a = 0xacffffffffff, .b = 0x71f3a315ad26},
{.a = 0xffffffffffff, .b = 0xffffffffffff},
{.a = 0xffffffffffff, .b = 0xffffffffffff},
{.a = 0xffffffffffff, .b = 0xffffffffffff},
{.a = 0xffffffffffff, .b = 0xffffffffffff}, //0
{.a = 0xffffffffffff, .b = 0xffffffffffff}, //1
{.a = 0xffffffffffff, .b = 0xffffffffffff}, //2
{.a = 0xffffffffffff, .b = 0xffffffffffff}, //3
{.a = 0xe56ac127dd45, .b = 0x19fc84a3784b}, //4
{.a = 0x77dabc9825e1, .b = 0x9764fec3154a}, //5
{.a = 0xffffffffffff, .b = 0xffffffffffff}, //6
{.a = 0xffffffffffff, .b = 0xffffffffffff}, //7
{.a = 0x26973ea74321, .b = 0xd27058c6e2c7}, //8
{.a = 0xeb0a8ff88ade, .b = 0x578a9ada41e3}, //9
{.a = 0xea0fd73cb149, .b = 0x29c35fa068fb}, //10
{.a = 0xc76bf71a2509, .b = 0x9ba241db3f56}, //11
{.a = 0xacffffffffff, .b = 0x71f3a315ad26}, //12
{.a = 0xffffffffffff, .b = 0xffffffffffff}, //13
{.a = 0xffffffffffff, .b = 0xffffffffffff}, //14
{.a = 0xffffffffffff, .b = 0xffffffffffff}, //15
{.a = 0x72f96bdd3714, .b = 0x462225cd34cf}, //16
{.a = 0x044ce1872bc3, .b = 0x8c90c70cff4a}, //17
{.a = 0xbc2d1791dec1, .b = 0xca96a487de0b}, //18
{.a = 0x8791b2ccb5c4, .b = 0xc956c3b80da3}, //19
{.a = 0x8e26e45e7d65, .b = 0x8e65b3af7d22}, //20
{.a = 0x0f318130ed18, .b = 0x0c420a20e056}, //21
{.a = 0x045ceca15535, .b = 0x31bec3d9e510}, //22
{.a = 0x9d993c5d4ef4, .b = 0x86120e488abf}, //23
{.a = 0xc65d4eaa645b, .b = 0xb69d40d1a439}, //24
{.a = 0x3a8a139c20b4, .b = 0x8818a9c5d406}, //25
{.a = 0xbaff3053b496, .b = 0x4b7cb25354d3}, //26
{.a = 0x7413b599c4ea, .b = 0xb0a2aaf3a1ba}, //27
{.a = 0x7413b599c4ea, .b = 0xb0a2aaf3a1ba}, //28
{.a = 0x0ce7cd2cc72b, .b = 0xfa1fbb3f0f1f}, //29
{.a = 0x0eb23cc8110b, .b = 0x04dc35277635}, //30
{.a = 0xbc4580b7f20b, .b = 0xd0a4131fb290}, //31
};
static const MfClassicKeyPair plantain_4k_keys[] = {
{.a = 0xffffffffffff, .b = 0xffffffffffff}, {.a = 0xffffffffffff, .b = 0xffffffffffff},
{.a = 0xffffffffffff, .b = 0xffffffffffff}, {.a = 0xffffffffffff, .b = 0xffffffffffff},
{.a = 0xe56ac127dd45, .b = 0x19fc84a3784b}, {.a = 0x77dabc9825e1, .b = 0x9764fec3154a},
{.a = 0xffffffffffff, .b = 0xffffffffffff}, {.a = 0xffffffffffff, .b = 0xffffffffffff},
{.a = 0x26973ea74321, .b = 0xd27058c6e2c7}, {.a = 0xeb0a8ff88ade, .b = 0x578a9ada41e3},
{.a = 0xea0fd73cb149, .b = 0x29c35fa068fb}, {.a = 0xc76bf71a2509, .b = 0x9ba241db3f56},
{.a = 0xacffffffffff, .b = 0x71f3a315ad26}, {.a = 0xffffffffffff, .b = 0xffffffffffff},
{.a = 0xffffffffffff, .b = 0xffffffffffff}, {.a = 0xffffffffffff, .b = 0xffffffffffff},
{.a = 0x72f96bdd3714, .b = 0x462225cd34cf}, {.a = 0x044ce1872bc3, .b = 0x8c90c70cff4a},
{.a = 0xbc2d1791dec1, .b = 0xca96a487de0b}, {.a = 0x8791b2ccb5c4, .b = 0xc956c3b80da3},
{.a = 0x8e26e45e7d65, .b = 0x8e65b3af7d22}, {.a = 0x0f318130ed18, .b = 0x0c420a20e056},
{.a = 0x045ceca15535, .b = 0x31bec3d9e510}, {.a = 0x9d993c5d4ef4, .b = 0x86120e488abf},
{.a = 0xc65d4eaa645b, .b = 0xb69d40d1a439}, {.a = 0x3a8a139c20b4, .b = 0x8818a9c5d406},
{.a = 0xbaff3053b496, .b = 0x4b7cb25354d3}, {.a = 0x7413b599c4ea, .b = 0xb0a2AAF3A1BA},
{.a = 0x0ce7cd2cc72b, .b = 0xfa1fbb3f0f1f}, {.a = 0x0be5fac8b06a, .b = 0x6f95887a4fd3},
{.a = 0x0eb23cc8110b, .b = 0x04dc35277635}, {.a = 0xbc4580b7f20b, .b = 0xd0a4131fb290},
{.a = 0x7a396f0d633d, .b = 0xad2bdc097023}, {.a = 0xa3faa6daff67, .b = 0x7600e889adf9},
{.a = 0xfd8705e721b0, .b = 0x296fc317a513}, {.a = 0x22052b480d11, .b = 0xe19504c39461},
{.a = 0xa7141147d430, .b = 0xff16014fefc7}, {.a = 0x8a8d88151a00, .b = 0x038b5f9b5a2a},
{.a = 0xb27addfb64b0, .b = 0x152fd0c420a7}, {.a = 0x7259fa0197c6, .b = 0x5583698df085},
{.a = 0xffffffffffff, .b = 0xffffffffffff}, //0
{.a = 0xffffffffffff, .b = 0xffffffffffff}, //1
{.a = 0xffffffffffff, .b = 0xffffffffffff}, //2
{.a = 0xffffffffffff, .b = 0xffffffffffff}, //3
{.a = 0xe56ac127dd45, .b = 0x19fc84a3784b}, //4
{.a = 0x77dabc9825e1, .b = 0x9764fec3154a}, //5
{.a = 0xffffffffffff, .b = 0xffffffffffff}, //6
{.a = 0xffffffffffff, .b = 0xffffffffffff}, //7
{.a = 0x26973ea74321, .b = 0xd27058c6e2c7}, //8
{.a = 0xeb0a8ff88ade, .b = 0x578a9ada41e3}, //9
{.a = 0xea0fd73cb149, .b = 0x29c35fa068fb}, //10
{.a = 0xc76bf71a2509, .b = 0x9ba241db3f56}, //11
{.a = 0xacffffffffff, .b = 0x71f3a315ad26}, //12
{.a = 0xffffffffffff, .b = 0xffffffffffff}, //13
{.a = 0xffffffffffff, .b = 0xffffffffffff}, //14
{.a = 0xffffffffffff, .b = 0xffffffffffff}, //15
{.a = 0x72f96bdd3714, .b = 0x462225cd34cf}, //16
{.a = 0x044ce1872bc3, .b = 0x8c90c70cff4a}, //17
{.a = 0xbc2d1791dec1, .b = 0xca96a487de0b}, //18
{.a = 0x8791b2ccb5c4, .b = 0xc956c3b80da3}, //19
{.a = 0x8e26e45e7d65, .b = 0x8e65b3af7d22}, //20
{.a = 0x0f318130ed18, .b = 0x0c420a20e056}, //21
{.a = 0x045ceca15535, .b = 0x31bec3d9e510}, //22
{.a = 0x9d993c5d4ef4, .b = 0x86120e488abf}, //23
{.a = 0xc65d4eaa645b, .b = 0xb69d40d1a439}, //24
{.a = 0x3a8a139c20b4, .b = 0x8818a9c5d406}, //25
{.a = 0xbaff3053b496, .b = 0x4b7cb25354d3}, //26
{.a = 0x7413b599c4ea, .b = 0xb0a2AAF3A1BA}, //27
{.a = 0x0ce7cd2cc72b, .b = 0xfa1fbb3f0f1f}, //28
{.a = 0x0be5fac8b06a, .b = 0x6f95887a4fd3}, //29
{.a = 0x0eb23cc8110b, .b = 0x04dc35277635}, //30
{.a = 0xbc4580b7f20b, .b = 0xd0a4131fb290}, //31
{.a = 0x7a396f0d633d, .b = 0xad2bdc097023}, //32
{.a = 0xa3faa6daff67, .b = 0x7600e889adf9}, //33
{.a = 0xfd8705e721b0, .b = 0x296fc317a513}, //34
{.a = 0x22052b480d11, .b = 0xe19504c39461}, //35
{.a = 0xa7141147d430, .b = 0xff16014fefc7}, //36
{.a = 0x8a8d88151a00, .b = 0x038b5f9b5a2a}, //37
{.a = 0xb27addfb64b0, .b = 0x152fd0c420a7}, //38
{.a = 0x7259fa0197c6, .b = 0x5583698df085}, //39
};
static const MfClassicKeyPair plantain_4k_keys_legacy[] = {
{.a = 0xffffffffffff, .b = 0xffffffffffff}, {.a = 0xffffffffffff, .b = 0xffffffffffff},
{.a = 0xffffffffffff, .b = 0xffffffffffff}, {.a = 0xffffffffffff, .b = 0xffffffffffff},
{.a = 0xe56ac127dd45, .b = 0x19fc84a3784b}, {.a = 0x77dabc9825e1, .b = 0x9764fec3154a},
{.a = 0xffffffffffff, .b = 0xffffffffffff}, {.a = 0xffffffffffff, .b = 0xffffffffffff},
{.a = 0x26973ea74321, .b = 0xd27058c6e2c7}, {.a = 0xeb0a8ff88ade, .b = 0x578a9ada41e3},
{.a = 0xea0fd73cb149, .b = 0x29c35fa068fb}, {.a = 0xc76bf71a2509, .b = 0x9ba241db3f56},
{.a = 0xacffffffffff, .b = 0x71f3a315ad26}, {.a = 0xffffffffffff, .b = 0xffffffffffff},
{.a = 0xffffffffffff, .b = 0xffffffffffff}, {.a = 0xffffffffffff, .b = 0xffffffffffff},
{.a = 0x72f96bdd3714, .b = 0x462225cd34cf}, {.a = 0x044ce1872bc3, .b = 0x8c90c70cff4a},
{.a = 0xbc2d1791dec1, .b = 0xca96a487de0b}, {.a = 0x8791b2ccb5c4, .b = 0xc956c3b80da3},
{.a = 0x8e26e45e7d65, .b = 0x8e65b3af7d22}, {.a = 0x0f318130ed18, .b = 0x0c420a20e056},
{.a = 0x045ceca15535, .b = 0x31bec3d9e510}, {.a = 0x9d993c5d4ef4, .b = 0x86120e488abf},
{.a = 0xc65d4eaa645b, .b = 0xb69d40d1a439}, {.a = 0x46d78e850a7e, .b = 0xa470f8130991},
{.a = 0x42e9b54e51ab, .b = 0x0231b86df52e}, {.a = 0x0f01ceff2742, .b = 0x6fec74559ca7},
{.a = 0xb81f2b0c2f66, .b = 0xa7e2d95f0003}, {.a = 0x9ea3387a63c1, .b = 0x437e59f57561},
{.a = 0x0eb23cc8110b, .b = 0x04dc35277635}, {.a = 0xbc4580b7f20b, .b = 0xd0a4131fb290},
{.a = 0x7a396f0d633d, .b = 0xad2bdc097023}, {.a = 0xa3faa6daff67, .b = 0x7600e889adf9},
{.a = 0xfd8705e721b0, .b = 0x296fc317a513}, {.a = 0x22052b480d11, .b = 0xe19504c39461},
{.a = 0xa7141147d430, .b = 0xff16014fefc7}, {.a = 0x8a8d88151a00, .b = 0x038b5f9b5a2a},
{.a = 0xb27addfb64b0, .b = 0x152fd0c420a7}, {.a = 0x7259fa0197c6, .b = 0x5583698df085},
{.a = 0xffffffffffff, .b = 0xffffffffffff}, //0
{.a = 0xffffffffffff, .b = 0xffffffffffff}, //1
{.a = 0xffffffffffff, .b = 0xffffffffffff}, //2
{.a = 0xffffffffffff, .b = 0xffffffffffff}, //3
{.a = 0xe56ac127dd45, .b = 0x19fc84a3784b}, //4
{.a = 0x77dabc9825e1, .b = 0x9764fec3154a}, //5
{.a = 0xffffffffffff, .b = 0xffffffffffff}, //6
{.a = 0xffffffffffff, .b = 0xffffffffffff}, //7
{.a = 0x26973ea74321, .b = 0xd27058c6e2c7}, //8
{.a = 0xeb0a8ff88ade, .b = 0x578a9ada41e3}, //9
{.a = 0xea0fd73cb149, .b = 0x29c35fa068fb}, //10
{.a = 0xc76bf71a2509, .b = 0x9ba241db3f56}, //11
{.a = 0xacffffffffff, .b = 0x71f3a315ad26}, //12
{.a = 0xffffffffffff, .b = 0xffffffffffff}, //13
{.a = 0xffffffffffff, .b = 0xffffffffffff}, //14
{.a = 0xffffffffffff, .b = 0xffffffffffff}, //15
{.a = 0x72f96bdd3714, .b = 0x462225cd34cf}, //16
{.a = 0x044ce1872bc3, .b = 0x8c90c70cff4a}, //17
{.a = 0xbc2d1791dec1, .b = 0xca96a487de0b}, //18
{.a = 0x8791b2ccb5c4, .b = 0xc956c3b80da3}, //19
{.a = 0x8e26e45e7d65, .b = 0x8e65b3af7d22}, //20
{.a = 0x0f318130ed18, .b = 0x0c420a20e056}, //21
{.a = 0x045ceca15535, .b = 0x31bec3d9e510}, //22
{.a = 0x9d993c5d4ef4, .b = 0x86120e488abf}, //23
{.a = 0xc65d4eaa645b, .b = 0xb69d40d1a439}, //24
{.a = 0x46d78e850a7e, .b = 0xa470f8130991}, //25
{.a = 0x42e9b54e51ab, .b = 0x0231b86df52e}, //26
{.a = 0x0f01ceff2742, .b = 0x6fec74559ca7}, //27
{.a = 0xb81f2b0c2f66, .b = 0xa7e2d95f0003}, //28
{.a = 0x9ea3387a63c1, .b = 0x437e59f57561}, //29
{.a = 0x0eb23cc8110b, .b = 0x04dc35277635}, //30
{.a = 0xbc4580b7f20b, .b = 0xd0a4131fb290}, //31
{.a = 0x7a396f0d633d, .b = 0xad2bdc097023}, //32
{.a = 0xa3faa6daff67, .b = 0x7600e889adf9}, //33
{.a = 0xfd8705e721b0, .b = 0x296fc317a513}, //34
{.a = 0x22052b480d11, .b = 0xe19504c39461}, //35
{.a = 0xa7141147d430, .b = 0xff16014fefc7}, //36
{.a = 0x8a8d88151a00, .b = 0x038b5f9b5a2a}, //37
{.a = 0xb27addfb64b0, .b = 0x152fd0c420a7}, //38
{.a = 0x7259fa0197c6, .b = 0x5583698df085}, //39
};
typedef struct {
uint16_t departure_uic;
uint16_t destination_uic;
uint16_t trip_start_uic;
uint16_t trip_end_uic;
FuriString* departure_name;
FuriString* destination_name;
FuriString* trip_start_sta_name;
FuriString* trip_end_sta_name;
uint8_t value_data;
uint8_t direction;
uint8_t current_status;
uint16_t valid_from_data;
uint16_t valid_till_data;
DateTime valid_from;
DateTime valid_till;
uint32_t tap_data;
DateTime tap_time;
uint8_t first_ticket_marker;
uint8_t second_ticket_marker;
uint64_t sys_n;
uint8_t ppk_cnt;
} PPKData;
typedef struct {
uint64_t card_number;
FuriString* card_number_str;
uint32_t balance;
uint8_t trips_metro;
uint8_t trips_ground;
uint32_t last_trip_data;
DateTime last_trip_time;
uint16_t validator;
uint16_t fare;
uint32_t last_payment_date_data;
DateTime last_payment_date;
uint16_t last_payment_amount;
uint8_t keyset;
} PlantainData;
// Function to map UIC codes to station names
static inline void sz_uic_to_sta(Storage* storage, const char* file_name, PPKData* ticket) {
FlipperFormat* file = flipper_format_file_alloc(storage);
FuriString* departure_uic = furi_string_alloc_printf("%04X", ticket->departure_uic);
FuriString* destination_uic = furi_string_alloc_printf("%04X", ticket->destination_uic);
FuriString* trip_start_uic = furi_string_alloc_printf("%04X", ticket->trip_start_uic);
FuriString* trip_end_uic = furi_string_alloc_printf("%04X", ticket->trip_end_uic);
if(flipper_format_file_open_existing(file, file_name)) {
flipper_format_read_string(
file, furi_string_get_cstr(departure_uic), ticket->departure_name);
flipper_format_rewind(file);
flipper_format_read_string(
file, furi_string_get_cstr(destination_uic), ticket->destination_name);
flipper_format_rewind(file);
flipper_format_read_string(
file, furi_string_get_cstr(trip_start_uic), ticket->trip_start_sta_name);
flipper_format_rewind(file);
flipper_format_read_string(
file, furi_string_get_cstr(trip_end_uic), ticket->trip_end_sta_name);
}
flipper_format_free(file);
furi_string_free(departure_uic);
furi_string_free(destination_uic);
furi_string_free(trip_start_uic);
furi_string_free(trip_end_uic);
}
// Function to resolve station names for a ticket, and if not found, set to "1E" + UIC code
static void resolve_station_name(Storage* storage, PPKData* ticket) {
sz_uic_to_sta(storage, EXT_PATH("nfc/assets/sz_id.nfc"), ticket);
if(furi_string_utf8_length(ticket->departure_name) <= 2) {
furi_string_printf(ticket->departure_name, "1E%04X", ticket->departure_uic);
}
if(furi_string_utf8_length(ticket->destination_name) <= 2) {
furi_string_printf(ticket->destination_name, "1E%04X", ticket->destination_uic);
}
if(furi_string_utf8_length(ticket->trip_start_sta_name) <= 2) {
furi_string_printf(ticket->trip_start_sta_name, "1E%04X", ticket->trip_start_uic);
}
if(furi_string_utf8_length(ticket->trip_end_sta_name) <= 2) {
furi_string_printf(ticket->trip_end_sta_name, "1E%04X", ticket->trip_end_uic);
}
}
// Function to extract plantain purse data from the card data
static inline void extract_purse_data(
const MfClassicData* data,
PlantainData* purse,
PPKData* ticket,
uint8_t first_ppk_ticket_offset,
uint8_t second_ppk_ticket_offset) {
uint64_t card_number = 0;
size_t uid_len = 0;
const uint8_t* uid = mf_classic_get_uid(data, &uid_len);
purse->card_number_str = furi_string_alloc();
const uint8_t* temp_ptr = &uid[0];
uint8_t card_number_tmp[uid_len];
if(uid_len == 4) {
for(size_t i = 0; i < 4; i++) {
card_number_tmp[i] = temp_ptr[3 - i];
}
} else if(uid_len == 7) {
for(size_t i = 0; i < 7; i++) {
card_number_tmp[i] = temp_ptr[6 - i];
}
} else {
return;
}
for(size_t i = 0; i < uid_len; i++) {
card_number = (card_number << 8) | card_number_tmp[i];
}
FuriString* card_number_s = furi_string_alloc();
furi_string_cat_printf(card_number_s, "%lld", card_number);
FuriString* tmp_s = furi_string_alloc_set_str("9643 3078 ");
for(uint8_t i = 0; i < 24; i += 4) {
for(uint8_t j = 0; j < 4; j++) {
furi_string_push_back(tmp_s, furi_string_get_char(card_number_s, i + j));
}
furi_string_push_back(tmp_s, ' ');
}
furi_string_set(purse->card_number_str, furi_string_get_cstr(tmp_s));
furi_string_free(card_number_s);
furi_string_free(tmp_s);
if(data->type == MfClassicType1k) {
uint32_t balance = 0;
for(uint8_t i = 0; i < 4; i++)
balance = (balance << 8) | data->block[16].data[3 - i];
balance /= 100;
purse->balance = balance;
purse->trips_metro = data->block[21].data[0];
purse->trips_ground = data->block[21].data[1];
uint32_t last_trip_timestamp = 0;
for(uint8_t i = 0; i < 3; i++) {
last_trip_timestamp = (last_trip_timestamp << 8) | data->block[21].data[4 - i];
}
for(uint8_t i = 0; i < 3; i++) {
purse->last_trip_data = (purse->last_trip_data << 8) | data->block[21].data[4 - i];
}
purse->validator = (data->block[20].data[5] << 8) | data->block[20].data[4];
uint16_t fare = ((data->block[20].data[7] << 8) | data->block[20].data[6]) / 100;
purse->fare = fare;
for(uint8_t i = 0; i < 3; i++) {
purse->last_payment_date_data = (purse->last_payment_date_data << 8) |
data->block[18].data[4 - i];
}
purse->last_payment_amount = ((data->block[18].data[10] << 16) |
(data->block[18].data[9] << 8) | (data->block[18].data[8])) /
100;
last_trip_timestamp = PLANTAIN_EPOCH_START + purse->last_trip_data * SECONDS_IN_A_MINUTE;
const uint32_t last_payment_timestamp =
PLANTAIN_EPOCH_START + purse->last_payment_date_data * SECONDS_IN_A_MINUTE;
datetime_timestamp_to_datetime(last_trip_timestamp, &purse->last_trip_time);
datetime_timestamp_to_datetime(last_payment_timestamp, &purse->last_payment_date);
} else if(data->type == MfClassicType4k) {
uint32_t balance = 0;
for(uint8_t i = 0; i < 4; i++)
balance = (balance << 8) | data->block[16].data[3 - i];
balance /= 100;
purse->balance = balance;
purse->trips_metro = data->block[21].data[0];
purse->trips_ground = data->block[21].data[1];
for(uint8_t i = 0; i < 3; i++) {
purse->last_trip_data = (purse->last_trip_data << 8) | data->block[21].data[4 - i];
}
purse->validator = (data->block[20].data[5] << 8) | data->block[20].data[4];
uint16_t fare = ((data->block[20].data[7] << 8) | data->block[20].data[6]) / 100;
purse->fare = fare;
for(uint8_t i = 0; i < 3; i++) {
purse->last_payment_date_data = (purse->last_payment_date_data << 8) |
data->block[18].data[4 - i];
}
purse->last_payment_amount = ((data->block[18].data[10] << 16) |
(data->block[18].data[9] << 8) | (data->block[18].data[8])) /
100;
uint32_t last_trip_timestamp =
PLANTAIN_EPOCH_START + purse->last_trip_data * SECONDS_IN_A_MINUTE;
const uint32_t last_payment_timestamp =
PLANTAIN_EPOCH_START + purse->last_payment_date_data * SECONDS_IN_A_MINUTE;
datetime_timestamp_to_datetime(last_trip_timestamp, &purse->last_trip_time);
datetime_timestamp_to_datetime(last_payment_timestamp, &purse->last_payment_date);
}
ticket->first_ticket_marker = data->block[first_ppk_ticket_offset].data[0];
ticket->second_ticket_marker = data->block[second_ppk_ticket_offset].data[0];
}
// Function to extract PPK ticket data from the card data
static inline void extract_ppk_data(
Storage* storage,
const MfClassicData* data,
PPKData* ticket,
bool second_ticket) {
const uint8_t* temp_ptr = &data->block[SECOND_TICKET_VALUE_BLOCK + 2].data[0];
uint8_t sys_n_arr[6] = {0};
if(second_ticket == 0) {
for(size_t i = 0; i < 6; i++) {
sys_n_arr[i] = temp_ptr[7 - i];
}
ticket->departure_uic = (data->block[FIRST_PPK_TICKET_OFFSET].data[6] << 8) |
(data->block[FIRST_PPK_TICKET_OFFSET].data[5]);
ticket->destination_uic = (data->block[FIRST_PPK_TICKET_OFFSET].data[9] << 8) |
(data->block[FIRST_PPK_TICKET_OFFSET].data[8]);
ticket->value_data = data->block[FIRST_TICKET_VALUE_BLOCK].data[0];
ticket->current_status = data->block[FIRST_TICKET_VALUE_BLOCK + 1].data[8];
ticket->valid_from_data = (data->block[FIRST_PPK_TICKET_OFFSET].data[2] << 8) |
(data->block[FIRST_PPK_TICKET_OFFSET].data[1]);
ticket->valid_till_data = (data->block[FIRST_PPK_TICKET_OFFSET].data[4] << 8) |
(data->block[FIRST_PPK_TICKET_OFFSET].data[3]);
ticket->direction = data->block[FIRST_PPK_TICKET_OFFSET].data[14];
ticket->tap_data = (data->block[FIRST_TICKET_VALUE_BLOCK + 1].data[2] << 16) |
(data->block[FIRST_TICKET_VALUE_BLOCK + 1].data[1] << 8) |
data->block[FIRST_TICKET_VALUE_BLOCK + 1].data[0];
ticket->ppk_cnt = data->block[105].data[10];
ticket->trip_start_uic = (data->block[FIRST_TICKET_VALUE_BLOCK + 1].data[4] << 8) |
(data->block[FIRST_TICKET_VALUE_BLOCK + 1].data[3]);
ticket->trip_end_uic = (data->block[FIRST_TICKET_VALUE_BLOCK + 1].data[7] << 8) |
(data->block[FIRST_TICKET_VALUE_BLOCK + 1].data[6]);
} else if(second_ticket == 1) {
for(size_t i = 0; i < 6; i++) {
sys_n_arr[i] = temp_ptr[13 - i];
}
ticket->departure_uic = (data->block[SECOND_PPK_TICKET_OFFSET].data[6] << 8) |
(data->block[SECOND_PPK_TICKET_OFFSET].data[5]);
ticket->destination_uic = (data->block[SECOND_PPK_TICKET_OFFSET].data[9] << 8) |
(data->block[SECOND_PPK_TICKET_OFFSET].data[8]);
ticket->value_data = data->block[SECOND_TICKET_VALUE_BLOCK].data[0];
ticket->current_status = data->block[SECOND_TICKET_VALUE_BLOCK + 1].data[8];
ticket->valid_from_data = (data->block[SECOND_PPK_TICKET_OFFSET].data[2] << 8) |
(data->block[SECOND_PPK_TICKET_OFFSET].data[1]);
ticket->valid_till_data = (data->block[SECOND_PPK_TICKET_OFFSET].data[4] << 8) |
(data->block[SECOND_PPK_TICKET_OFFSET].data[3]);
ticket->direction = data->block[SECOND_PPK_TICKET_OFFSET].data[14];
ticket->tap_data = (data->block[SECOND_TICKET_VALUE_BLOCK + 1].data[2] << 16) |
(data->block[SECOND_TICKET_VALUE_BLOCK + 1].data[1] << 8) |
data->block[SECOND_TICKET_VALUE_BLOCK + 1].data[0];
ticket->ppk_cnt = data->block[109].data[10];
ticket->trip_start_uic = (data->block[SECOND_TICKET_VALUE_BLOCK + 1].data[4] << 8) |
(data->block[SECOND_TICKET_VALUE_BLOCK + 1].data[3]);
ticket->trip_end_uic = (data->block[SECOND_TICKET_VALUE_BLOCK + 1].data[7] << 8) |
(data->block[SECOND_TICKET_VALUE_BLOCK + 1].data[6]);
}
ticket->first_ticket_marker = data->block[FIRST_PPK_TICKET_OFFSET].data[0];
ticket->second_ticket_marker = data->block[SECOND_PPK_TICKET_OFFSET].data[0];
uint64_t sys_n = 0;
for(size_t i = 0; i < 6; i++) {
sys_n = (sys_n << 8) | sys_n_arr[i];
}
ticket->sys_n = sys_n;
const uint32_t valid_from_timestamp =
PPK_WHOLE_EPOCH_START + ticket->valid_from_data * SECONDS_IN_A_DAY;
const uint32_t valid_till_timestamp =
PPK_WHOLE_EPOCH_START + ticket->valid_till_data * SECONDS_IN_A_DAY;
const uint32_t tap_timestamp =
PPK_CURRENT_EPOCH_START + ticket->tap_data * SECONDS_IN_A_MINUTE;
datetime_timestamp_to_datetime(valid_from_timestamp, &ticket->valid_from);
datetime_timestamp_to_datetime(valid_till_timestamp, &ticket->valid_till);
datetime_timestamp_to_datetime(tap_timestamp, &ticket->tap_time);
resolve_station_name(storage, ticket);
}
// Function to format and print plantain purse data
static void printf_plantain_data(FuriString* parsed_data, PlantainData* purse) {
furi_string_printf(parsed_data, "\e#Plantain Card");
furi_string_cat_printf(
parsed_data,
"\nNumber: %s\nBalance: %ld RUB\nMetro Trips: %d\nGround Trips: %d\nLast Trip: %02d.%02d.%04d %02d:%02d",
furi_string_get_cstr(purse->card_number_str),
purse->balance,
purse->trips_metro,
purse->trips_ground,
purse->last_trip_time.day,
purse->last_trip_time.month,
purse->last_trip_time.year,
purse->last_trip_time.hour,
purse->last_trip_time.minute);
furi_string_cat_printf(
parsed_data,
"\nValidator: %d\nFare: %d RUB\nRefilled on: %02d.%02d.%04d %02d:%02d\nAmount: %d RUB",
purse->validator,
purse->fare,
purse->last_payment_date.day,
purse->last_payment_date.month,
purse->last_payment_date.year,
purse->last_payment_date.hour,
purse->last_payment_date.minute,
purse->last_payment_amount);
if(purse->keyset == 1)
furi_string_cat_printf(parsed_data, "\nPPK keys installed:> YES");
else
furi_string_cat_printf(parsed_data, "\nPPK keys installed:> NO");
furi_string_free(purse->card_number_str);
}
// Function to format and print PPK ticket data
static void printf_ppk_data(FuriString* parsed_data, PPKData* ticket, bool ticket_number) {
if(ticket_number == 0) {
furi_string_cat_printf(parsed_data, "\n\n\e#PPK Ticket:\n");
switch(ticket->first_ticket_marker) {
case 0x33:
furi_string_cat_printf(parsed_data, "Type:> 1 ride");
break;
case 0x34:
furi_string_cat_printf(parsed_data, "Type:> 2 rides (Mon.-Fri.)");
break;
case 0x35:
furi_string_cat_printf(parsed_data, "Type:> 2 rides (Fri.-Mon.)");
break;
case 0x36:
furi_string_cat_printf(parsed_data, "Type:> 2 rides (Sat.-Mon.");
break;
default:
furi_string_cat_printf(
parsed_data, "Type:> Unknown, 0x%02X", ticket->first_ticket_marker);
}
} else if(ticket_number == 1) {
furi_string_cat_printf(parsed_data, "\n\nSecond PPK Ticket:\n");
switch(ticket->second_ticket_marker) {
case 0x33:
furi_string_cat_printf(parsed_data, "Type:> 1 ride");
break;
case 0x34:
furi_string_cat_printf(parsed_data, "Type:> 2 rides (Mon.-Fri.)");
break;
case 0x35:
furi_string_cat_printf(parsed_data, "Type:> 2 rides (Fri.-Mon.)");
break;
case 0x36:
furi_string_cat_printf(parsed_data, "Type:> 2 rides (Sat.-Mon.)");
break;
default:
furi_string_cat_printf(
parsed_data, "Type:> Unknown, 0x%02X", ticket->second_ticket_marker);
}
}
furi_string_cat_printf(
parsed_data,
"\nFrom:> %s\nTo:> %s",
furi_string_get_cstr(ticket->departure_name),
furi_string_get_cstr(ticket->destination_name));
if(ticket->valid_from.day == ticket->valid_till.day) {
furi_string_cat_printf(
parsed_data,
"\nValid On: %02d-%02d-%04d",
ticket->valid_from.day,
ticket->valid_from.month,
ticket->valid_from.year);
} else {
furi_string_cat_printf(
parsed_data,
"\nValid From: %02d-%02d-%04d\nValid thru: %02d-%02d-%04d",
ticket->valid_from.day,
ticket->valid_from.month,
ticket->valid_from.year,
ticket->valid_till.day,
ticket->valid_till.month,
ticket->valid_till.year);
}
if(ticket->direction == 1) {
furi_string_cat_printf(parsed_data, "\nDirection: One-way ->>");
} else if(ticket->direction == 2) {
furi_string_cat_printf(parsed_data, "\nDirection: Round-trip <<-->>");
}
furi_string_cat_printf(parsed_data, "\nRides left:> %02d", ticket->value_data);
if(ticket->current_status == 0) {
furi_string_cat_printf(parsed_data, "\nStatus:> TICKET IS READY\n");
} else if(ticket->current_status == 0x80)
furi_string_cat_printf(
parsed_data,
"\nStatus:> ENTERED STATION\nSta name:> %s\nLast pass on:> %02d-%02d-%04d\nPass time:> %02d:%02d\n",
furi_string_get_cstr(ticket->trip_start_sta_name),
ticket->tap_time.day,
ticket->tap_time.month,
ticket->tap_time.year,
ticket->tap_time.hour,
ticket->tap_time.minute);
else if(ticket->current_status == 0x1E)
furi_string_cat_printf(
parsed_data,
"\nStatus:> EXITED STATION\nSta name:> %s\nLast pass on:> %02d-%02d-%04d\nPass time:> %02d:%02d\n",
furi_string_get_cstr(ticket->trip_end_sta_name),
ticket->tap_time.day,
ticket->tap_time.month,
ticket->tap_time.year,
ticket->tap_time.hour,
ticket->tap_time.minute);
else
furi_string_cat_printf(parsed_data, "\nStatus:> UNKNOWN (%02X)\n", ticket->current_status);
furi_string_cat_printf(
parsed_data, "SYS N:> %lld\nPPK CNT:> %03d", ticket->sys_n, ticket->ppk_cnt);
}
//Function to select a keyset based on card type
static bool plantain_get_card_config(PlantainCardConfig* config, MfClassicType type) {
bool success = true;
if(type == MfClassicType1k) {
config->data_sector = 8;
config->keys = plantain_1k_keys;
} else if(type == MfClassicType4k) {
config->data_sector = 8;
config->keys = plantain_4k_keys;
} else {
success = false;
}
@@ -114,11 +582,10 @@ static bool plantain_verify_type(Nfc* nfc, MfClassicType type) {
bool verified = false;
do {
PlantainCardConfig cfg = {};
PlantainCardConfig cfg = {0};
if(!plantain_get_card_config(&cfg, type)) break;
const uint8_t block_num = mf_classic_get_first_block_num_of_sector(cfg.data_sector);
FURI_LOG_D(TAG, "Verifying sector %lu", cfg.data_sector);
MfClassicKey key = {0};
bit_lib_num_to_bytes_be(cfg.keys[cfg.data_sector].a, COUNT_OF(key.data), key.data);
@@ -127,7 +594,6 @@ static bool plantain_verify_type(Nfc* nfc, MfClassicType type) {
MfClassicError error =
mf_classic_poller_sync_auth(nfc, block_num, &key, MfClassicKeyTypeA, &auth_context);
if(error != MfClassicErrorNone) {
FURI_LOG_D(TAG, "Failed to read block %u: %d", block_num, error);
break;
}
@@ -157,7 +623,7 @@ static bool plantain_read(Nfc* nfc, NfcDevice* device) {
if(error != MfClassicErrorNone) break;
data->type = type;
PlantainCardConfig cfg = {};
PlantainCardConfig cfg = {0};
if(!plantain_get_card_config(&cfg, data->type)) break;
const uint8_t legacy_check_sec_num = 26;
@@ -171,21 +637,28 @@ static bool plantain_read(Nfc* nfc, NfcDevice* device) {
error = mf_classic_poller_sync_auth(
nfc, legacy_check_block_num, &key, MfClassicKeyTypeA, NULL);
if(error == MfClassicErrorNone) {
FURI_LOG_D(TAG, "Legacy keys detected");
cfg.keys = plantain_4k_keys_legacy;
}
MfClassicDeviceKeys keys = {};
for(size_t i = 0; i < mf_classic_get_total_sectors_num(data->type); i++) {
bit_lib_num_to_bytes_be(cfg.keys[i].a, sizeof(MfClassicKey), keys.key_a[i].data);
FURI_BIT_SET(keys.key_a_mask, i);
bit_lib_num_to_bytes_be(cfg.keys[i].b, sizeof(MfClassicKey), keys.key_b[i].data);
FURI_BIT_SET(keys.key_b_mask, i);
if(data->type == MfClassicType1k) {
for(size_t i = 0; i < 32; i++) {
bit_lib_num_to_bytes_be(cfg.keys[i].a, sizeof(MfClassicKey), keys.key_a[i].data);
FURI_BIT_SET(keys.key_a_mask, i);
bit_lib_num_to_bytes_be(cfg.keys[i].b, sizeof(MfClassicKey), keys.key_b[i].data);
FURI_BIT_SET(keys.key_b_mask, i);
}
} else {
for(size_t i = 0; i < mf_classic_get_total_sectors_num(data->type); i++) {
bit_lib_num_to_bytes_be(cfg.keys[i].a, sizeof(MfClassicKey), keys.key_a[i].data);
FURI_BIT_SET(keys.key_a_mask, i);
bit_lib_num_to_bytes_be(cfg.keys[i].b, sizeof(MfClassicKey), keys.key_b[i].data);
FURI_BIT_SET(keys.key_b_mask, i);
}
}
error = mf_classic_poller_sync_read(nfc, &keys, data);
if(error == MfClassicErrorNotPresent) {
FURI_LOG_W(TAG, "Failed to read data");
break;
}
@@ -198,18 +671,22 @@ static bool plantain_read(Nfc* nfc, NfcDevice* device) {
return is_read;
}
//Main parsing function
static bool plantain_parse(const NfcDevice* device, FuriString* parsed_data) {
furi_assert(device);
size_t uid_len = 0;
const MfClassicData* data = nfc_device_get_data(device, NfcProtocolMfClassic);
const uint8_t* uid = mf_classic_get_uid(data, &uid_len);
PPKData ticket = {0};
ticket.departure_name = furi_string_alloc();
ticket.destination_name = furi_string_alloc();
ticket.trip_start_sta_name = furi_string_alloc();
ticket.trip_end_sta_name = furi_string_alloc();
PlantainData purse = {0};
Storage* storage = furi_record_open(RECORD_STORAGE);
bool parsed = false;
do {
// Verify card type
PlantainCardConfig cfg = {};
PlantainCardConfig cfg = {0};
if(!plantain_get_card_config(&cfg, data->type)) break;
// Verify key
@@ -220,167 +697,47 @@ static bool plantain_parse(const NfcDevice* device, FuriString* parsed_data) {
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;
furi_string_printf(parsed_data, "\e#Plantain card\n");
if(data->block[107].data[10] == 0x02)
purse.keyset = 0;
else
purse.keyset = 1;
const uint8_t* temp_ptr = &uid[0];
// UID is read from last to first byte
uint8_t card_number_tmp[uid_len];
if(uid_len == 4) {
for(size_t i = 0; i < 4; i++) {
card_number_tmp[i] = temp_ptr[3 - i];
}
} else if(uid_len == 7) {
for(size_t i = 0; i < 7; i++) {
card_number_tmp[i] = temp_ptr[6 - i];
}
} else {
break;
// Extract plantain purse data and fill PPK tickets markers
extract_purse_data(
data, &purse, &ticket, FIRST_PPK_TICKET_OFFSET, SECOND_PPK_TICKET_OFFSET);
// Print plantain purse data
printf_plantain_data(parsed_data, &purse);
if(purse.card_number_str) furi_string_free(purse.card_number_str);
// Extract and print PPK ticket data if present
if(ticket.first_ticket_marker != 0) {
extract_ppk_data(storage, data, &ticket, 0);
printf_ppk_data(parsed_data, &ticket, false);
}
//UID is converted to a card number
uint64_t card_number = 0;
for(size_t i = 0; i < uid_len; i++) {
card_number = (card_number << 8) | card_number_tmp[i];
// Extract and print second PPK ticket data if present
if(ticket.second_ticket_marker != 0) {
PPKData second_ticket = {0};
second_ticket.departure_name = furi_string_alloc();
second_ticket.destination_name = furi_string_alloc();
second_ticket.trip_start_sta_name = furi_string_alloc();
second_ticket.trip_end_sta_name = furi_string_alloc();
extract_ppk_data(storage, data, &second_ticket, 1);
printf_ppk_data(parsed_data, &second_ticket, true);
furi_string_free(second_ticket.departure_name);
furi_string_free(second_ticket.destination_name);
furi_string_free(second_ticket.trip_start_sta_name);
furi_string_free(second_ticket.trip_end_sta_name);
}
// Print card number with 4-digit groups. "3" in "3078" denotes a ticket type "3 - full ticket", will differ on discounted cards.
furi_string_cat_printf(parsed_data, "Number: ");
FuriString* card_number_s = furi_string_alloc();
furi_string_cat_printf(card_number_s, "%lld", card_number);
FuriString* tmp_s = furi_string_alloc_set_str("9643 3078 ");
for(uint8_t i = 0; i < 24; i += 4) {
for(uint8_t j = 0; j < 4; j++) {
furi_string_push_back(tmp_s, furi_string_get_char(card_number_s, i + j));
}
furi_string_push_back(tmp_s, ' ');
}
furi_string_cat_printf(parsed_data, "%s\n", furi_string_get_cstr(tmp_s));
// this works for 2K Plantain
if(data->type == MfClassicType1k) {
//balance
uint32_t balance = 0;
for(uint8_t i = 0; i < 4; i++) {
balance = (balance << 8) | data->block[16].data[3 - i];
}
furi_string_cat_printf(parsed_data, "Balance: %ld rub\n", balance / 100);
//trips
uint8_t trips_metro = data->block[21].data[0];
uint8_t trips_ground = data->block[21].data[1];
furi_string_cat_printf(parsed_data, "Trips: %d\n", trips_metro + trips_ground);
//trip time
uint32_t last_trip_timestamp = 0;
for(uint8_t i = 0; i < 3; i++) {
last_trip_timestamp = (last_trip_timestamp << 8) | data->block[21].data[4 - i];
}
DateTime last_trip = {0};
from_minutes_to_datetime(last_trip_timestamp + 24 * 60, &last_trip, 2010);
furi_string_cat_printf(
parsed_data,
"Trip start: %02d.%02d.%04d %02d:%02d\n",
last_trip.day,
last_trip.month,
last_trip.year,
last_trip.hour,
last_trip.minute);
//validator
uint16_t validator = (data->block[20].data[5] << 8) | data->block[20].data[4];
furi_string_cat_printf(parsed_data, "Validator: %d\n", validator);
//tariff
uint16_t fare = (data->block[20].data[7] << 8) | data->block[20].data[6];
furi_string_cat_printf(parsed_data, "Tariff: %d rub\n", fare / 100);
//trips in metro
furi_string_cat_printf(parsed_data, "Trips (Metro): %d\n", trips_metro);
//trips on ground
furi_string_cat_printf(parsed_data, "Trips (Ground): %d\n", trips_ground);
//last payment
uint32_t last_payment_timestamp = 0;
for(uint8_t i = 0; i < 3; i++) {
last_payment_timestamp = (last_payment_timestamp << 8) |
data->block[18].data[4 - i];
}
DateTime last_payment_date = {0};
from_minutes_to_datetime(last_payment_timestamp + 24 * 60, &last_payment_date, 2010);
furi_string_cat_printf(
parsed_data,
"Last pay: %02d.%02d.%04d %02d:%02d\n",
last_payment_date.day,
last_payment_date.month,
last_payment_date.year,
last_payment_date.hour,
last_payment_date.minute);
//Last payment amount.
uint16_t last_payment = ((data->block[18].data[10] << 16) |
(data->block[18].data[9] << 8) | (data->block[18].data[8])) /
100;
furi_string_cat_printf(parsed_data, "Amount: %d rub", last_payment);
furi_string_free(card_number_s);
furi_string_free(tmp_s);
//This is for 4K Plantains.
} else if(data->type == MfClassicType4k) {
//balance
uint32_t balance = 0;
for(uint8_t i = 0; i < 4; i++) {
balance = (balance << 8) | data->block[16].data[3 - i];
}
furi_string_cat_printf(parsed_data, "Balance: %ld rub\n", balance / 100);
//trips
uint8_t trips_metro = data->block[21].data[0];
uint8_t trips_ground = data->block[21].data[1];
furi_string_cat_printf(parsed_data, "Trips: %d\n", trips_metro + trips_ground);
//trip time
uint32_t last_trip_timestamp = 0;
for(uint8_t i = 0; i < 3; i++) {
last_trip_timestamp = (last_trip_timestamp << 8) | data->block[21].data[4 - i];
}
DateTime last_trip = {0};
from_minutes_to_datetime(last_trip_timestamp + 24 * 60, &last_trip, 2010);
furi_string_cat_printf(
parsed_data,
"Trip start: %02d.%02d.%04d %02d:%02d\n",
last_trip.day,
last_trip.month,
last_trip.year,
last_trip.hour,
last_trip.minute);
//validator
uint16_t validator = (data->block[20].data[5] << 8) | data->block[20].data[4];
furi_string_cat_printf(parsed_data, "Validator: %d\n", validator);
//tariff
uint16_t fare = (data->block[20].data[7] << 8) | data->block[20].data[6];
furi_string_cat_printf(parsed_data, "Tariff: %d rub\n", fare / 100);
//trips in metro
furi_string_cat_printf(parsed_data, "Trips (Metro): %d\n", trips_metro);
//trips on ground
furi_string_cat_printf(parsed_data, "Trips (Ground): %d\n", trips_ground);
//last payment
uint32_t last_payment_timestamp = 0;
for(uint8_t i = 0; i < 3; i++) {
last_payment_timestamp = (last_payment_timestamp << 8) |
data->block[18].data[4 - i];
}
DateTime last_payment_date = {0};
from_minutes_to_datetime(last_payment_timestamp + 24 * 60, &last_payment_date, 2010);
furi_string_cat_printf(
parsed_data,
"Last pay: %02d.%02d.%04d %02d:%02d\n",
last_payment_date.day,
last_payment_date.month,
last_payment_date.year,
last_payment_date.hour,
last_payment_date.minute);
//Last payment amount
uint16_t last_payment = ((data->block[18].data[10] << 16) |
(data->block[18].data[9] << 8) | (data->block[18].data[8])) /
100;
furi_string_cat_printf(parsed_data, "Amount: %d rub", last_payment);
furi_string_free(card_number_s);
furi_string_free(tmp_s);
}
parsed = true;
} while(false);
furi_string_free(ticket.departure_name);
furi_string_free(ticket.destination_name);
furi_string_free(ticket.trip_start_sta_name);
furi_string_free(ticket.trip_end_sta_name);
furi_record_close(RECORD_STORAGE);
return parsed;
}
@@ -0,0 +1,399 @@
//Based on parsers written by Leptoptilos and Assasinfil. Also, thanks to WillyJL (<me@willyjl.dev>) for help!
#include "nfc_supported_card_plugin.h"
#include <flipper_application/flipper_application.h>
#include <nfc/nfc_device.h>
#include <bit_lib/bit_lib.h>
#include <datetime.h>
#include <nfc/protocols/mf_classic/mf_classic_poller_sync.h>
#include <flipper_format/flipper_format.h>
#define PPK_WHOLE_EPOCH_START 946684800 //2000-01-01
#define PPK_CURRENT_EPOCH_START 1388534400 //2014-01-01
#define SECONDS_IN_A_DAY 86400
#define SECONDS_IN_A_MINUTE 60
#define FIRST_PPK_TICKET_OFFSET 76
#define FIRST_TICKET_VALUE_BLOCK 77
#define SECOND_PPK_TICKET_OFFSET 88
#define SECOND_TICKET_VALUE_BLOCK 89
typedef struct {
uint64_t a;
uint64_t b;
} MfClassicKeyPair;
typedef struct {
uint16_t departure_uic;
uint16_t destination_uic;
uint16_t trip_start_uic;
uint16_t trip_end_uic;
FuriString* departure_name;
FuriString* destination_name;
FuriString* trip_start_sta_name;
FuriString* trip_end_sta_name;
uint8_t value_data;
uint8_t current_status;
uint16_t valid_from_data;
uint16_t valid_till_data;
DateTime valid_from;
DateTime valid_till;
uint32_t tap_data;
DateTime tap_time;
uint8_t first_ticket_marker;
uint8_t second_ticket_marker;
uint8_t ppk_cnt;
} TicketData;
static const MfClassicKeyPair t_card_2k[] = {
{.a = 0xFFFFFFFFFFFF, .b = 0xFFFFFFFFFFFF}, //0
{.a = 0xFFFFFFFFFFFF, .b = 0xFFFFFFFFFFFF}, //1
{.a = 0xFFFFFFFFFFFF, .b = 0xFFFFFFFFFFFF}, //2
{.a = 0xFFFFFFFFFFFF, .b = 0xFFFFFFFFFFFF}, //3
{.a = 0xFFFFFFFFFFFF, .b = 0xFFFFFFFFFFFF}, //4
{.a = 0xFFFFFFFFFFFF, .b = 0xFFFFFFFFFFFF}, //5
{.a = 0xFFFFFFFFFFFF, .b = 0xFFFFFFFFFFFF}, //6
{.a = 0xFFFFFFFFFFFF, .b = 0xFFFFFFFFFFFF}, //7
{.a = 0xFFFFFFFFFFFF, .b = 0xFFFFFFFFFFFF}, //8
{.a = 0xFFFFFFFFFFFF, .b = 0xFFFFFFFFFFFF}, //9
{.a = 0xFFFFFFFFFFFF, .b = 0xFFFFFFFFFFFF}, //10
{.a = 0xFFFFFFFFFFFF, .b = 0xFFFFFFFFFFFF}, //11
{.a = 0xFFFFFFFFFFFF, .b = 0xFFFFFFFFFFFF}, //12
{.a = 0xFFFFFFFFFFFF, .b = 0xFFFFFFFFFFFF}, //13
{.a = 0xFFFFFFFFFFFF, .b = 0xFFFFFFFFFFFF}, //14
{.a = 0xFFFFFFFFFFFF, .b = 0xFFFFFFFFFFFF}, //15
{.a = 0x061029A4A6A4, .b = 0xCF43E52FC23D}, //16
{.a = 0xE09F2EC8229C, .b = 0x044D78B70F92}, //17
{.a = 0x66843B4FF86E, .b = 0x4474EB131577}, //18
{.a = 0x9AC9A9B5E809, .b = 0x558B8E45E568}, //19
{.a = 0xC114DE1AD90F, .b = 0xA0A9033E2A09}, //20
{.a = 0xF696EFFDD838, .b = 0xA38333EC3F72}, //21
{.a = 0xAB50BDE9CD04, .b = 0x67991D678AED}, //22
{.a = 0x9041DD7B236C, .b = 0x8A527C8CA237}, //23
{.a = 0x7D40A5AA63D0, .b = 0x4A61C563DD8A}, //24
{.a = 0x05D0520EC52B, .b = 0xB40BD14E6CB4}, //25
{.a = 0x9354F1BFF80F, .b = 0xF99D40CBBA63}, //26
{.a = 0x84132D9FD1AF, .b = 0x1BAA2563C14D}, //27
{.a = 0x27A8E6436B01, .b = 0x4DB883715A5C}, //28
{.a = 0x58B173568D26, .b = 0x2E4ADD66E35E}, //29
{.a = 0x1BE71601E73C, .b = 0xB855F30C54FB}, //30
{.a = 0xFFFFFFFFFFFF, .b = 0xFFFFFFFFFFFF}, //31
};
static inline void sz_uic_to_sta(Storage* storage, const char* file_name, TicketData* ticket) {
FlipperFormat* file = flipper_format_file_alloc(storage);
FuriString* departure_uic = furi_string_alloc_printf("%04X", ticket->departure_uic);
FuriString* destination_uic = furi_string_alloc_printf("%04X", ticket->destination_uic);
FuriString* trip_start_uic = furi_string_alloc_printf("%04X", ticket->trip_start_uic);
FuriString* trip_end_uic = furi_string_alloc_printf("%04X", ticket->trip_end_uic);
if(flipper_format_file_open_existing(file, file_name)) {
flipper_format_read_string(
file, furi_string_get_cstr(departure_uic), ticket->departure_name);
flipper_format_rewind(file);
flipper_format_read_string(
file, furi_string_get_cstr(destination_uic), ticket->destination_name);
flipper_format_rewind(file);
flipper_format_read_string(
file, furi_string_get_cstr(trip_start_uic), ticket->trip_start_sta_name);
flipper_format_rewind(file);
flipper_format_read_string(
file, furi_string_get_cstr(trip_end_uic), ticket->trip_end_sta_name);
}
flipper_format_free(file);
furi_string_free(departure_uic);
furi_string_free(destination_uic);
furi_string_free(trip_start_uic);
furi_string_free(trip_end_uic);
}
static void resolve_station_name(Storage* storage, TicketData* ticket) {
sz_uic_to_sta(storage, EXT_PATH("nfc/assets/sev_id.nfc"), ticket);
if(furi_string_utf8_length(ticket->departure_name) <= 2) {
furi_string_printf(ticket->departure_name, "1f%04X", ticket->departure_uic);
}
if(furi_string_utf8_length(ticket->destination_name) <= 2) {
furi_string_printf(ticket->destination_name, "1F%04X", ticket->destination_uic);
}
if(furi_string_utf8_length(ticket->trip_start_sta_name) <= 2) {
furi_string_printf(ticket->trip_start_sta_name, "1F%04X", ticket->trip_start_uic);
}
if(furi_string_utf8_length(ticket->trip_end_sta_name) <= 2) {
furi_string_printf(ticket->trip_end_sta_name, "1F%04X", ticket->trip_end_uic);
}
}
static inline void extract_ppk_data(
Storage* storage,
const MfClassicData* data,
TicketData* ticket,
bool ticket_number) {
if(ticket_number == 0) {
ticket->departure_uic = (data->block[FIRST_PPK_TICKET_OFFSET].data[6] << 8) |
(data->block[FIRST_PPK_TICKET_OFFSET].data[5]);
ticket->destination_uic = (data->block[FIRST_PPK_TICKET_OFFSET].data[9] << 8) |
(data->block[FIRST_PPK_TICKET_OFFSET].data[8]);
ticket->value_data = data->block[FIRST_TICKET_VALUE_BLOCK].data[0];
ticket->current_status = data->block[FIRST_TICKET_VALUE_BLOCK + 1].data[8];
ticket->valid_from_data = (data->block[FIRST_PPK_TICKET_OFFSET].data[2] << 8) |
(data->block[FIRST_PPK_TICKET_OFFSET].data[1]);
ticket->valid_till_data = (data->block[FIRST_PPK_TICKET_OFFSET].data[4] << 8) |
(data->block[FIRST_PPK_TICKET_OFFSET].data[3]);
ticket->tap_data = (data->block[FIRST_TICKET_VALUE_BLOCK + 1].data[2] << 16) |
(data->block[FIRST_TICKET_VALUE_BLOCK + 1].data[1] << 8) |
data->block[FIRST_TICKET_VALUE_BLOCK + 1].data[0];
ticket->ppk_cnt = data->block[FIRST_TICKET_VALUE_BLOCK + 1].data[10];
ticket->trip_start_uic = (data->block[FIRST_TICKET_VALUE_BLOCK + 1].data[4] << 8) |
(data->block[FIRST_TICKET_VALUE_BLOCK + 1].data[3]);
ticket->trip_end_uic = (data->block[FIRST_TICKET_VALUE_BLOCK + 1].data[7] << 8) |
(data->block[FIRST_TICKET_VALUE_BLOCK + 1].data[6]);
} else {
ticket->departure_uic = (data->block[SECOND_PPK_TICKET_OFFSET].data[6] << 8) |
(data->block[SECOND_PPK_TICKET_OFFSET].data[5]);
ticket->destination_uic = (data->block[SECOND_PPK_TICKET_OFFSET].data[9] << 8) |
(data->block[SECOND_PPK_TICKET_OFFSET].data[8]);
ticket->value_data = data->block[SECOND_TICKET_VALUE_BLOCK].data[0];
ticket->current_status = data->block[SECOND_TICKET_VALUE_BLOCK + 1].data[8];
ticket->valid_from_data = (data->block[SECOND_PPK_TICKET_OFFSET].data[2] << 8) |
(data->block[SECOND_PPK_TICKET_OFFSET].data[1]);
ticket->valid_till_data = (data->block[SECOND_PPK_TICKET_OFFSET].data[4] << 8) |
(data->block[SECOND_PPK_TICKET_OFFSET].data[3]);
ticket->tap_data = (data->block[SECOND_TICKET_VALUE_BLOCK + 1].data[2] << 16) |
(data->block[SECOND_TICKET_VALUE_BLOCK + 1].data[1] << 8) |
data->block[SECOND_TICKET_VALUE_BLOCK + 1].data[0];
ticket->ppk_cnt = data->block[SECOND_TICKET_VALUE_BLOCK + 1].data[10];
ticket->trip_start_uic = (data->block[SECOND_TICKET_VALUE_BLOCK + 1].data[4] << 8) |
(data->block[SECOND_TICKET_VALUE_BLOCK + 1].data[3]);
ticket->trip_end_uic = (data->block[SECOND_TICKET_VALUE_BLOCK + 1].data[7] << 8) |
(data->block[SECOND_TICKET_VALUE_BLOCK + 1].data[6]);
}
ticket->first_ticket_marker = data->block[FIRST_PPK_TICKET_OFFSET].data[0];
ticket->second_ticket_marker = data->block[SECOND_PPK_TICKET_OFFSET].data[0];
const uint32_t valid_from_timestamp =
PPK_WHOLE_EPOCH_START + ticket->valid_from_data * SECONDS_IN_A_DAY;
const uint32_t valid_till_timestamp =
PPK_WHOLE_EPOCH_START + ticket->valid_till_data * SECONDS_IN_A_DAY;
const uint32_t tap_timestamp =
PPK_CURRENT_EPOCH_START + ticket->tap_data * SECONDS_IN_A_MINUTE;
datetime_timestamp_to_datetime(valid_from_timestamp, &ticket->valid_from);
datetime_timestamp_to_datetime(valid_till_timestamp, &ticket->valid_till);
datetime_timestamp_to_datetime(tap_timestamp, &ticket->tap_time);
resolve_station_name(storage, ticket);
}
static void
printf_transport_card(FuriString* parsed_data, TicketData* ticket, bool ticket_number) {
if(ticket->departure_uic == 0x0000) {
furi_string_cat_printf(
parsed_data,
"\e#Unknown SevPPK Card\n NO TICKET DATA FOUND \n\nTHE TICKET IS NOT ISSUED\nOR LAYOUT IS UNKNOWN\n");
return;
} else {
if(ticket_number == 0) {
furi_string_cat_printf(parsed_data, "\e#SevPPK Transport Card\n");
switch(ticket->first_ticket_marker) {
case 0x02:
furi_string_cat_printf(parsed_data, "Type:> 5 days unlim.");
break;
case 0x06:
furi_string_cat_printf(parsed_data, "Type:> 10 rides");
break;
case 0x18:
furi_string_cat_printf(parsed_data, "Type:> 30 days unlim.");
break;
case 0x1B:
furi_string_cat_printf(parsed_data, "Type:> 20 rides");
break;
default:
furi_string_cat_printf(
parsed_data, "Type: Unknown, 0x%02X", ticket->first_ticket_marker);
}
} else {
furi_string_cat_printf(parsed_data, "\e#Second Ticket:");
switch(ticket->second_ticket_marker) {
case 0x02:
furi_string_cat_printf(parsed_data, "Type:> 5 days unlim.");
break;
case 0x06:
furi_string_cat_printf(parsed_data, "Type:> 10 rides");
break;
case 0x18:
furi_string_cat_printf(parsed_data, "Type:> 30 days unlim.");
break;
case 0x1B:
furi_string_cat_printf(parsed_data, "Type:> 20 rides");
break;
default:
furi_string_cat_printf(
parsed_data, "Type: Unknown, 0x%02X", ticket->second_ticket_marker);
}
}
}
furi_string_cat_printf(
parsed_data,
"\nFrom:>%s\nTo:>%s\nValid From: %02d-%02d-%04d\nValid thru: %02d-%02d-%04d\n",
furi_string_get_cstr(ticket->departure_name),
furi_string_get_cstr(ticket->destination_name),
ticket->valid_from.day,
ticket->valid_from.month,
ticket->valid_from.year,
ticket->valid_till.day,
ticket->valid_till.month,
ticket->valid_till.year);
if(ticket->value_data > 0) {
furi_string_cat_printf(parsed_data, "Rides remain: %02d\n", ticket->value_data);
}
switch(ticket->current_status) {
case 0x00:
furi_string_cat_printf(parsed_data, "Status:> NOT USED\n");
break;
case 0x80:
furi_string_cat_printf(
parsed_data,
"Status:> ENTERED STATION\nSta name:> %s\nLast pass on:> %02d-%02d-%04d\nPass time:> %02d:%02d\nPPK CNT: %03d\n",
furi_string_get_cstr(ticket->trip_start_sta_name),
ticket->tap_time.day,
ticket->tap_time.month,
ticket->tap_time.year,
ticket->tap_time.hour,
ticket->tap_time.minute,
ticket->ppk_cnt);
break;
case 0x1F:
furi_string_cat_printf(
parsed_data,
"Status:> EXITED STATION\nSta name:> %s\nLast pass on:> %02d-%02d-%04d\nPass time:> %02d:%02d\nPPK CNT: %03d\n",
furi_string_get_cstr(ticket->trip_end_sta_name),
ticket->tap_time.day,
ticket->tap_time.month,
ticket->tap_time.year,
ticket->tap_time.hour,
ticket->tap_time.minute,
ticket->ppk_cnt);
break;
default:
furi_string_cat_printf(
parsed_data,
"Status:> UNKNOWN (%02X)\nPPK CNT: %03d",
ticket->current_status,
ticket->ppk_cnt);
break;
}
}
bool sev_tk_verify(Nfc* nfc) {
const uint8_t verify_sector = 19;
const uint8_t block_num = mf_classic_get_first_block_num_of_sector(verify_sector);
MfClassicKey key = {};
bit_lib_num_to_bytes_be(t_card_2k[verify_sector].a, COUNT_OF(key.data), key.data);
MfClassicAuthContext auth_ctx = {};
MfClassicError error =
mf_classic_poller_sync_auth(nfc, block_num, &key, MfClassicKeyTypeA, &auth_ctx);
return error == MfClassicErrorNone;
}
static bool sev_tk_read(Nfc* nfc, NfcDevice* device) {
furi_assert(nfc);
furi_assert(device);
MfClassicData* data = mf_classic_alloc();
nfc_device_copy_data(device, NfcProtocolMfClassic, data);
bool is_read = false;
MfClassicType type = MfClassicType1k;
MfClassicError error = mf_classic_poller_sync_detect_type(nfc, &type);
if(error == MfClassicErrorNone) {
data->type = type;
MfClassicDeviceKeys keys = {};
for(size_t i = 0; i < 32; i++) {
bit_lib_num_to_bytes_be(t_card_2k[i].a, sizeof(MfClassicKey), keys.key_a[i].data);
FURI_BIT_SET(keys.key_a_mask, i);
bit_lib_num_to_bytes_be(t_card_2k[i].b, sizeof(MfClassicKey), keys.key_b[i].data);
FURI_BIT_SET(keys.key_b_mask, i);
}
error = mf_classic_poller_sync_read(nfc, &keys, data);
if(error != MfClassicErrorNotPresent) {
nfc_device_set_data(device, NfcProtocolMfClassic, data);
is_read = (error == MfClassicErrorNone);
}
}
mf_classic_free(data);
return is_read;
}
static bool sev_tk_parse(const NfcDevice* device, FuriString* parsed_data) {
furi_assert(device);
const MfClassicData* data = nfc_device_get_data(device, NfcProtocolMfClassic);
bool parsed = false;
do {
MfClassicSectorTrailer* sec_tr = mf_classic_get_sector_trailer_by_sector(data, 19);
uint64_t key = bit_lib_bytes_to_num_be(sec_tr->key_a.data, 6);
if(key != t_card_2k[19].a) break;
Storage* storage = furi_record_open(RECORD_STORAGE);
TicketData primary_ticket = {0};
primary_ticket.departure_name = furi_string_alloc();
primary_ticket.destination_name = furi_string_alloc();
primary_ticket.trip_start_sta_name = furi_string_alloc();
primary_ticket.trip_end_sta_name = furi_string_alloc();
extract_ppk_data(storage, data, &primary_ticket, 0);
printf_transport_card(parsed_data, &primary_ticket, false);
furi_string_free(primary_ticket.departure_name);
furi_string_free(primary_ticket.destination_name);
furi_string_free(primary_ticket.trip_start_sta_name);
furi_string_free(primary_ticket.trip_end_sta_name);
if(primary_ticket.second_ticket_marker != 0) {
TicketData secondary_ticket = {0};
secondary_ticket.departure_name = furi_string_alloc();
secondary_ticket.destination_name = furi_string_alloc();
secondary_ticket.trip_start_sta_name = furi_string_alloc();
secondary_ticket.trip_end_sta_name = furi_string_alloc();
extract_ppk_data(storage, data, &secondary_ticket, 1);
printf_transport_card(parsed_data, &secondary_ticket, true);
furi_string_free(primary_ticket.departure_name);
furi_string_free(primary_ticket.destination_name);
furi_string_free(primary_ticket.trip_start_sta_name);
furi_string_free(primary_ticket.trip_end_sta_name);
}
furi_record_close(RECORD_STORAGE);
parsed = true;
} while(false);
return parsed;
}
static const NfcSupportedCardsPlugin sev_tk_plugin = {
.protocol = NfcProtocolMfClassic,
.verify = sev_tk_verify,
.read = sev_tk_read,
.parse = sev_tk_parse,
};
static const FlipperAppPluginDescriptor sev_tk_plugin_descriptor = {
.appid = NFC_SUPPORTED_CARD_PLUGIN_APP_ID,
.ep_api_version = NFC_SUPPORTED_CARD_PLUGIN_API_VERSION,
.entry_point = &sev_tk_plugin,
};
const FlipperAppPluginDescriptor* sev_tk_plugin_ep(void) {
return &sev_tk_plugin_descriptor;
}
@@ -0,0 +1,406 @@
//Based on parsers written by Leptoptilos and Assasinfil. Also, thanks to WillyJL (<me@willyjl.dev>) for help!
#include "nfc_supported_card_plugin.h"
#include <flipper_application/flipper_application.h>
#include <nfc/nfc_device.h>
#include <bit_lib/bit_lib.h>
#include <datetime.h>
#include <nfc/protocols/mf_classic/mf_classic_poller_sync.h>
#include <flipper_format/flipper_format.h>
#define PPK_WHOLE_EPOCH_START 946684800 //2000-01-01
#define PPK_CURRENT_EPOCH_START 1388534400 //2014-01-01
#define SECONDS_IN_A_DAY 86400
#define SECONDS_IN_A_MINUTE 60
#define FIRST_PPK_TICKET_OFFSET 76
#define FIRST_TICKET_VALUE_BLOCK 77
#define SECOND_PPK_TICKET_OFFSET 88
#define SECOND_TICKET_VALUE_BLOCK 89
typedef struct {
uint64_t a;
uint64_t b;
} MfClassicKeyPair;
typedef struct {
uint16_t departure_uic;
uint16_t destination_uic;
uint16_t trip_start_uic;
uint16_t trip_end_uic;
FuriString* departure_name;
FuriString* destination_name;
FuriString* trip_start_sta_name;
FuriString* trip_end_sta_name;
uint8_t value_data;
uint8_t current_status;
uint16_t valid_from_data;
uint16_t valid_till_data;
DateTime valid_from;
DateTime valid_till;
uint32_t tap_data;
DateTime tap_time;
uint8_t first_ticket_marker;
uint8_t second_ticket_marker;
uint8_t ppk_cnt;
} TicketData;
static const MfClassicKeyPair t_card_4k[] = {
{.a = 0xFFFFFFFFFFFF, .b = 0xB0B4B2B1B3B6}, //0
{.a = 0xFFFFFFFFFFFF, .b = 0xB0B5B2B4B9B0}, //1
{.a = 0xFFFFFFFFFFFF, .b = 0xB0B8B4B6B1B3}, //2
{.a = 0xFFFFFFFFFFFF, .b = 0xB0B0B3B8B5B7}, //3
{.a = 0xFFFFFFFFFFFF, .b = 0xB0B0B6B4B2B8}, //4
{.a = 0xFFFFFFFFFFFF, .b = 0xB0B1B4B5B3B8}, //5
{.a = 0xFFFFFFFFFFFF, .b = 0xB0B0B8B1B7B8}, //6
{.a = 0xFFFFFFFFFFFF, .b = 0xB0B1B5B1B8B1}, //7
{.a = 0xFFFFFFFFFFFF, .b = 0xB0B0B6B4B8B7}, //8
{.a = 0xFFFFFFFFFFFF, .b = 0xB0B9B2B9B2B4}, //9
{.a = 0xFFFFFFFFFFFF, .b = 0xB0B1B1B8B0B6}, //10
{.a = 0xFFFFFFFFFFFF, .b = 0xB0B7B8B9B5B5}, //11
{.a = 0xFFFFFFFFFFFF, .b = 0xB0B0B7B1B0B0}, //12
{.a = 0xFFFFFFFFFFFF, .b = 0xB0B9B2B8B3B2}, //13
{.a = 0xFFFFFFFFFFFF, .b = 0xB0B4B1B0B4B6}, //14
{.a = 0xFFFFFFFFFFFF, .b = 0xB0B4B2B5B4B8}, //15
{.a = 0x684CE8377AB8, .b = 0x91888D728D9E}, //16
{.a = 0xF462ED255B44, .b = 0x0D40620AC610}, //17
{.a = 0xF1E8FD5B5C9F, .b = 0xABB5763550B0}, //18
{.a = 0xE9E3265B45B1, .b = 0x11D848B26034}, //19
{.a = 0xAEA3755DFA82, .b = 0x36D1BAC1395E}, //20
{.a = 0x83F58B205854, .b = 0x967DAFCF2674}, //21
{.a = 0x5CDF18E68A75, .b = 0x1689C175B14E}, //22
{.a = 0x126DC25C5D53, .b = 0x346B03AF1FF3}, //23
{.a = 0xF1B013C4495C, .b = 0x74CE14DBC71F}, //24
{.a = 0xA5FE4FAD0269, .b = 0x0025FEA845E5}, //25
{.a = 0x43080428049C, .b = 0x2E91BB6F511E}, //26
{.a = 0x4F44A08C51BC, .b = 0xE44CC58DF833}, //27
{.a = 0x3FEC92F652BE, .b = 0x942039006B83}, //28
{.a = 0x3A3BE5B635FA, .b = 0xFC564425A9BA}, //29
{.a = 0x78C9E1C688BB, .b = 0xA29E362B22F3}, //30
{.a = 0xFFFFFFFFFFFF, .b = 0xB0B1B2B3B4B5}, //31
{.a = 0xFFFFFFFFFFFF, .b = 0xB0B1B2B3B4B5}, //32
{.a = 0xFFFFFFFFFFFF, .b = 0xB0B1B2B3B4B5}, //33
{.a = 0xFFFFFFFFFFFF, .b = 0xB0B1B2B3B4B5}, //34
{.a = 0xFFFFFFFFFFFF, .b = 0xB0B1B2B3B4B5}, //35
{.a = 0xFFFFFFFFFFFF, .b = 0xB0B1B2B3B4B5}, //36
{.a = 0xFFFFFFFFFFFF, .b = 0xB0B1B2B3B4B5}, //37
{.a = 0xFFFFFFFFFFFF, .b = 0xB0B1B2B3B4B5}, //38
{.a = 0xFFFFFFFFFFFF, .b = 0xB0B1B2B3B4B5}, //39
};
static inline void sz_uic_to_sta(Storage* storage, const char* file_name, TicketData* ticket) {
FlipperFormat* file = flipper_format_file_alloc(storage);
FuriString* departure_uic = furi_string_alloc_printf("%04X", ticket->departure_uic);
FuriString* destination_uic = furi_string_alloc_printf("%04X", ticket->destination_uic);
FuriString* trip_start_uic = furi_string_alloc_printf("%04X", ticket->trip_start_uic);
FuriString* trip_end_uic = furi_string_alloc_printf("%04X", ticket->trip_end_uic);
if(flipper_format_file_open_existing(file, file_name)) {
flipper_format_read_string(
file, furi_string_get_cstr(departure_uic), ticket->departure_name);
flipper_format_rewind(file);
flipper_format_read_string(
file, furi_string_get_cstr(destination_uic), ticket->destination_name);
flipper_format_rewind(file);
flipper_format_read_string(
file, furi_string_get_cstr(trip_start_uic), ticket->trip_start_sta_name);
flipper_format_rewind(file);
flipper_format_read_string(
file, furi_string_get_cstr(trip_end_uic), ticket->trip_end_sta_name);
}
flipper_format_free(file);
furi_string_free(departure_uic);
furi_string_free(destination_uic);
furi_string_free(trip_start_uic);
furi_string_free(trip_end_uic);
}
static void resolve_station_name(Storage* storage, TicketData* ticket) {
sz_uic_to_sta(storage, EXT_PATH("nfc/assets/sk_id.nfc"), ticket);
if(furi_string_utf8_length(ticket->departure_name) <= 2) {
furi_string_printf(ticket->departure_name, "1f%04X", ticket->departure_uic);
}
if(furi_string_utf8_length(ticket->destination_name) <= 2) {
furi_string_printf(ticket->destination_name, "1F%04X", ticket->destination_uic);
}
if(furi_string_utf8_length(ticket->trip_start_sta_name) <= 2) {
furi_string_printf(ticket->trip_start_sta_name, "1F%04X", ticket->trip_start_uic);
}
if(furi_string_utf8_length(ticket->trip_end_sta_name) <= 2) {
furi_string_printf(ticket->trip_end_sta_name, "1F%04X", ticket->trip_end_uic);
}
}
static inline void extract_ppk_data(
Storage* storage,
const MfClassicData* data,
TicketData* ticket,
bool ticket_number) {
if(ticket_number == 0) {
ticket->departure_uic = (data->block[FIRST_PPK_TICKET_OFFSET].data[6] << 8) |
(data->block[FIRST_PPK_TICKET_OFFSET].data[5]);
ticket->destination_uic = (data->block[FIRST_PPK_TICKET_OFFSET].data[9] << 8) |
(data->block[FIRST_PPK_TICKET_OFFSET].data[8]);
ticket->value_data = data->block[FIRST_TICKET_VALUE_BLOCK].data[0];
ticket->current_status = data->block[FIRST_TICKET_VALUE_BLOCK + 1].data[8];
ticket->valid_from_data = (data->block[FIRST_PPK_TICKET_OFFSET].data[2] << 8) |
(data->block[FIRST_PPK_TICKET_OFFSET].data[1]);
ticket->valid_till_data = (data->block[FIRST_PPK_TICKET_OFFSET].data[4] << 8) |
(data->block[FIRST_PPK_TICKET_OFFSET].data[3]);
ticket->tap_data = (data->block[FIRST_TICKET_VALUE_BLOCK + 1].data[2] << 16) |
(data->block[FIRST_TICKET_VALUE_BLOCK + 1].data[1] << 8) |
data->block[FIRST_TICKET_VALUE_BLOCK + 1].data[0];
ticket->ppk_cnt = data->block[FIRST_TICKET_VALUE_BLOCK + 1].data[10];
ticket->trip_start_uic = (data->block[FIRST_TICKET_VALUE_BLOCK + 1].data[4] << 8) |
(data->block[FIRST_TICKET_VALUE_BLOCK + 1].data[3]);
ticket->trip_end_uic = (data->block[FIRST_TICKET_VALUE_BLOCK + 1].data[7] << 8) |
(data->block[FIRST_TICKET_VALUE_BLOCK + 1].data[6]);
} else {
ticket->departure_uic = (data->block[SECOND_PPK_TICKET_OFFSET].data[6] << 8) |
(data->block[SECOND_PPK_TICKET_OFFSET].data[5]);
ticket->destination_uic = (data->block[SECOND_PPK_TICKET_OFFSET].data[9] << 8) |
(data->block[SECOND_PPK_TICKET_OFFSET].data[8]);
ticket->value_data = data->block[SECOND_TICKET_VALUE_BLOCK].data[0];
ticket->current_status = data->block[SECOND_TICKET_VALUE_BLOCK + 1].data[8];
ticket->valid_from_data = (data->block[SECOND_PPK_TICKET_OFFSET].data[2] << 8) |
(data->block[SECOND_PPK_TICKET_OFFSET].data[1]);
ticket->valid_till_data = (data->block[SECOND_PPK_TICKET_OFFSET].data[4] << 8) |
(data->block[SECOND_PPK_TICKET_OFFSET].data[3]);
ticket->tap_data = (data->block[SECOND_TICKET_VALUE_BLOCK + 1].data[2] << 16) |
(data->block[SECOND_TICKET_VALUE_BLOCK + 1].data[1] << 8) |
data->block[SECOND_TICKET_VALUE_BLOCK + 1].data[0];
ticket->ppk_cnt = data->block[SECOND_TICKET_VALUE_BLOCK + 1].data[10];
ticket->trip_start_uic = (data->block[SECOND_TICKET_VALUE_BLOCK + 1].data[4] << 8) |
(data->block[SECOND_TICKET_VALUE_BLOCK + 1].data[3]);
ticket->trip_end_uic = (data->block[SECOND_TICKET_VALUE_BLOCK + 1].data[7] << 8) |
(data->block[SECOND_TICKET_VALUE_BLOCK + 1].data[6]);
}
ticket->first_ticket_marker = data->block[FIRST_PPK_TICKET_OFFSET].data[0];
ticket->second_ticket_marker = data->block[SECOND_PPK_TICKET_OFFSET].data[0];
const uint32_t valid_from_timestamp =
PPK_WHOLE_EPOCH_START + ticket->valid_from_data * SECONDS_IN_A_DAY;
const uint32_t valid_till_timestamp =
PPK_WHOLE_EPOCH_START + ticket->valid_till_data * SECONDS_IN_A_DAY;
const uint32_t tap_timestamp =
PPK_CURRENT_EPOCH_START + ticket->tap_data * SECONDS_IN_A_MINUTE;
datetime_timestamp_to_datetime(valid_from_timestamp, &ticket->valid_from);
datetime_timestamp_to_datetime(valid_till_timestamp, &ticket->valid_till);
datetime_timestamp_to_datetime(tap_timestamp, &ticket->tap_time);
resolve_station_name(storage, ticket);
}
static void
printf_transport_card(FuriString* parsed_data, TicketData* ticket, bool ticket_number) {
if(ticket->departure_uic == 0x0000) {
furi_string_cat_printf(
parsed_data,
"\e#Unknown SKPPK Card\n NO TICKET DATA FOUND \n\nTHE TICKET IS NOT ISSUED\nOR LAYOUT IS UNKNOWN\n");
return;
} else {
if(ticket_number == 0) {
furi_string_cat_printf(parsed_data, "\e#SKPPK Transport Card\n");
switch(ticket->first_ticket_marker) {
case 0x02:
furi_string_cat_printf(parsed_data, "Type:> 5 days unlim.");
break;
case 0x06:
furi_string_cat_printf(parsed_data, "Type:> 10 rides");
break;
case 0x18:
furi_string_cat_printf(parsed_data, "Type:> 30 days unlim.");
break;
case 0x1B:
furi_string_cat_printf(parsed_data, "Type:> 20 rides.");
break;
default:
furi_string_cat_printf(
parsed_data, "Type: Unknown, 0x%02X\n", ticket->first_ticket_marker);
}
} else {
furi_string_cat_printf(parsed_data, "\e#Second Ticket:\n");
switch(ticket->second_ticket_marker) {
case 0x02:
furi_string_cat_printf(parsed_data, "Type:> 5 days unlim.");
break;
case 0x06:
furi_string_cat_printf(parsed_data, "Type:> 10 rides");
break;
case 0x18:
furi_string_cat_printf(parsed_data, "Type:> 30 days unlim.");
break;
case 0x1B:
furi_string_cat_printf(parsed_data, "Type:> 20 rides.");
break;
default:
furi_string_cat_printf(
parsed_data, "Type: Unknown, 0x%02X", ticket->second_ticket_marker);
}
}
furi_string_cat_printf(
parsed_data,
"\nFrom:>%s\nTo:>%s\nValid From: %02d-%02d-%04d\nValid thru: %02d-%02d-%04d\n",
furi_string_get_cstr(ticket->departure_name),
furi_string_get_cstr(ticket->destination_name),
ticket->valid_from.day,
ticket->valid_from.month,
ticket->valid_from.year,
ticket->valid_till.day,
ticket->valid_till.month,
ticket->valid_till.year);
if(ticket->value_data > 0) {
furi_string_cat_printf(parsed_data, "Rides remain: %02d\n", ticket->value_data);
}
switch(ticket->current_status) {
case 0x00:
furi_string_cat_printf(parsed_data, "Status:> NOT USED\n");
break;
case 0x80:
furi_string_cat_printf(
parsed_data,
"Status:> ENTERED STATION\nSta name:> %s\nLast pass on:> %02d-%02d-%04d\nPass time:> %02d:%02d\nPPK CNT: %03d\n",
furi_string_get_cstr(ticket->trip_start_sta_name),
ticket->tap_time.day,
ticket->tap_time.month,
ticket->tap_time.year,
ticket->tap_time.hour,
ticket->tap_time.minute,
ticket->ppk_cnt);
break;
case 0x1F:
furi_string_cat_printf(
parsed_data,
"Status:> EXITED STATION\nSta name:> %s\nLast pass on:> %02d-%02d-%04d\nPass time:> %02d:%02d\nPPK CNT: %03d\n",
furi_string_get_cstr(ticket->trip_end_sta_name),
ticket->tap_time.day,
ticket->tap_time.month,
ticket->tap_time.year,
ticket->tap_time.hour,
ticket->tap_time.minute,
ticket->ppk_cnt);
break;
default:
furi_string_cat_printf(
parsed_data,
"Status:> UNKNOWN (%02X)\nPPK CNT: %03d",
ticket->current_status,
ticket->ppk_cnt);
break;
}
}
}
bool sk_tk_verify(Nfc* nfc) {
const uint8_t verify_sector = 19;
const uint8_t block_num = mf_classic_get_first_block_num_of_sector(verify_sector);
MfClassicKey key = {};
bit_lib_num_to_bytes_be(t_card_4k[verify_sector].a, COUNT_OF(key.data), key.data);
MfClassicAuthContext auth_ctx = {};
MfClassicError error =
mf_classic_poller_sync_auth(nfc, block_num, &key, MfClassicKeyTypeA, &auth_ctx);
return error == MfClassicErrorNone;
}
static bool sk_tk_read(Nfc* nfc, NfcDevice* device) {
furi_assert(nfc);
furi_assert(device);
MfClassicData* data = mf_classic_alloc();
nfc_device_copy_data(device, NfcProtocolMfClassic, data);
bool is_read = false;
MfClassicType type = MfClassicType4k;
MfClassicError error = mf_classic_poller_sync_detect_type(nfc, &type);
if(error == MfClassicErrorNone) {
data->type = type;
MfClassicDeviceKeys keys = {};
for(size_t i = 0; i < mf_classic_get_total_sectors_num(data->type); i++) {
bit_lib_num_to_bytes_be(t_card_4k[i].a, sizeof(MfClassicKey), keys.key_a[i].data);
FURI_BIT_SET(keys.key_a_mask, i);
bit_lib_num_to_bytes_be(t_card_4k[i].b, sizeof(MfClassicKey), keys.key_b[i].data);
FURI_BIT_SET(keys.key_b_mask, i);
}
error = mf_classic_poller_sync_read(nfc, &keys, data);
if(error != MfClassicErrorNotPresent) {
nfc_device_set_data(device, NfcProtocolMfClassic, data);
is_read = (error == MfClassicErrorNone);
}
}
mf_classic_free(data);
return is_read;
}
static bool sk_tk_parse(const NfcDevice* device, FuriString* parsed_data) {
furi_assert(device);
const MfClassicData* data = nfc_device_get_data(device, NfcProtocolMfClassic);
bool parsed = false;
do {
MfClassicSectorTrailer* sec_tr = mf_classic_get_sector_trailer_by_sector(data, 19);
uint64_t key = bit_lib_bytes_to_num_be(sec_tr->key_a.data, 6);
if(key != t_card_4k[19].a) break;
Storage* storage = furi_record_open(RECORD_STORAGE);
TicketData primary_ticket = {0};
primary_ticket.departure_name = furi_string_alloc();
primary_ticket.destination_name = furi_string_alloc();
primary_ticket.trip_start_sta_name = furi_string_alloc();
primary_ticket.trip_end_sta_name = furi_string_alloc();
extract_ppk_data(storage, data, &primary_ticket, 0);
printf_transport_card(parsed_data, &primary_ticket, false);
furi_string_free(primary_ticket.departure_name);
furi_string_free(primary_ticket.destination_name);
furi_string_free(primary_ticket.trip_start_sta_name);
furi_string_free(primary_ticket.trip_end_sta_name);
if(primary_ticket.second_ticket_marker != 0) {
TicketData secondary_ticket = {0};
secondary_ticket.departure_name = furi_string_alloc();
secondary_ticket.destination_name = furi_string_alloc();
secondary_ticket.trip_start_sta_name = furi_string_alloc();
secondary_ticket.trip_end_sta_name = furi_string_alloc();
extract_ppk_data(storage, data, &secondary_ticket, 1);
printf_transport_card(parsed_data, &secondary_ticket, true);
furi_string_free(primary_ticket.departure_name);
furi_string_free(primary_ticket.destination_name);
furi_string_free(primary_ticket.trip_start_sta_name);
furi_string_free(primary_ticket.trip_end_sta_name);
}
furi_record_close(RECORD_STORAGE);
parsed = true;
} while(false);
return parsed;
}
static const NfcSupportedCardsPlugin sk_tk_plugin = {
.protocol = NfcProtocolMfClassic,
.verify = sk_tk_verify,
.read = sk_tk_read,
.parse = sk_tk_parse,
};
static const FlipperAppPluginDescriptor sk_tk_plugin_descriptor = {
.appid = NFC_SUPPORTED_CARD_PLUGIN_APP_ID,
.ep_api_version = NFC_SUPPORTED_CARD_PLUGIN_API_VERSION,
.entry_point = &sk_tk_plugin,
};
const FlipperAppPluginDescriptor* sk_tk_plugin_ep(void) {
return &sk_tk_plugin_descriptor;
}
@@ -0,0 +1,449 @@
//Based on parsers written by Leptoptilos and Assasinfil. Also, thanks to WillyJL (<me@willyjl.dev>) for help!
#include "nfc_supported_card_plugin.h"
#include <flipper_application/flipper_application.h>
#include <nfc/nfc_device.h>
#include <bit_lib/bit_lib.h>
#include <datetime.h>
#include <nfc/protocols/mf_classic/mf_classic_poller_sync.h>
#include <flipper_format/flipper_format.h>
#define PPK_WHOLE_EPOCH_START 946684800 //2000-01-01
#define PPK_CURRENT_EPOCH_START 1388534400 //1388530800 //2014-01-01
#define SECONDS_IN_A_DAY 86400
#define SECONDS_IN_A_MINUTE 60
#define FIRST_PPK_TICKET_OFFSET 76
#define FIRST_TICKET_VALUE_BLOCK 77
#define SECOND_PPK_TICKET_OFFSET 88
#define SECOND_TICKET_VALUE_BLOCK 89
typedef struct {
uint64_t a;
uint64_t b;
} MfClassicKeyPair;
typedef struct {
uint16_t departure_uic;
uint16_t destination_uic;
uint16_t trip_start_uic;
uint16_t trip_end_uic;
FuriString* departure_name;
FuriString* destination_name;
FuriString* trip_start_sta_name;
FuriString* trip_end_sta_name;
uint8_t value_data;
uint8_t current_status;
uint16_t valid_from_data;
uint16_t valid_till_data;
DateTime valid_from;
DateTime valid_till;
uint32_t tap_data;
DateTime tap_time;
uint8_t first_ticket_marker;
uint8_t second_ticket_marker;
uint8_t ppk_cnt;
} TicketData;
static const MfClassicKeyPair so_card_2k[] = {
{.a = 0xFFFFFFFFFFFF, .b = 0xB0B7B5B8B4B5}, //0
{.a = 0xFFFFFFFFFFFF, .b = 0xB0B6B2B9B8B7}, //1
{.a = 0xFFFFFFFFFFFF, .b = 0xB0B2B4B4B4B0}, //2
{.a = 0xFFFFFFFFFFFF, .b = 0xB0B3B5B2B2B2}, //3
{.a = 0xFFFFFFFFFFFF, .b = 0xB0B8B4B8B0B9}, //4
{.a = 0xFFFFFFFFFFFF, .b = 0xB0B3B7B9B8B6}, //5
{.a = 0xFFFFFFFFFFFF, .b = 0xB0B6B5B5B5B6}, //6
{.a = 0xFFFFFFFFFFFF, .b = 0xB0B7B0B0B2B0}, //7
{.a = 0xFFFFFFFFFFFF, .b = 0xB0B1B1B2B8B0}, //8
{.a = 0xFFFFFFFFFFFF, .b = 0xB0B0B1B8B3B2}, //9
{.a = 0xFFFFFFFFFFFF, .b = 0xB0B0B0B3B1B8}, //10
{.a = 0xFFFFFFFFFFFF, .b = 0xB0B2B6B7B5B2}, //11
{.a = 0xFFFFFFFFFFFF, .b = 0xB0B9B2B0B2B0}, //12
{.a = 0xFFFFFFFFFFFF, .b = 0xB0B7B8B8B7B3}, //13
{.a = 0xFFFFFFFFFFFF, .b = 0xB0B3B4B9B2B0}, //14
{.a = 0xFFFFFFFFFFFF, .b = 0xB0B7B2B0B0B9}, //15
{.a = 0xA94CAB611187, .b = 0x389109BD1D82}, //16
{.a = 0xBF4280329F11, .b = 0x28D9EDD2096D}, //17
{.a = 0xDE6BD90BD6B0, .b = 0x94866C16E9A4}, //18
{.a = 0x2EA9493CAA7C, .b = 0x5068BCE2BC1C}, //19
{.a = 0x15A41BA53F6C, .b = 0x3BD3CF43571C}, //20
{.a = 0x1290FFD80DB5, .b = 0xD821B7916B7E}, //21
{.a = 0x68C1A07E96A9, .b = 0x2B3323E75750}, //22
{.a = 0xC699831AB307, .b = 0xCD7F7E9111F1}, //23
{.a = 0x4E5884BF23E9, .b = 0x2287812A6AEE}, //24
{.a = 0xC55212F716DC, .b = 0x594E368CCEFF}, //25
{.a = 0x6EF127E674B1, .b = 0xDD21C8D3E0B9}, //26
{.a = 0xFB79FAF4B55C, .b = 0xFE52B3B2A93B}, //27
{.a = 0x6CF85CDFF647, .b = 0xCCAD7C41FC8A}, //28
{.a = 0x591F6C130F91, .b = 0x2D2B734ECF91}, //29
{.a = 0xEEB83529B79B, .b = 0xCB14E70EBA38}, //30
{.a = 0xFFFFFFFFFFFF, .b = 0xB0B1B2B3B4B5}, //31
};
static inline void sz_uic_to_sta(Storage* storage, const char* file_name, TicketData* ticket) {
FlipperFormat* file = flipper_format_file_alloc(storage);
FuriString* departure_uic = furi_string_alloc_printf("%04X", ticket->departure_uic);
FuriString* destination_uic = furi_string_alloc_printf("%04X", ticket->destination_uic);
FuriString* trip_start_uic = furi_string_alloc_printf("%04X", ticket->trip_start_uic);
FuriString* trip_end_uic = furi_string_alloc_printf("%04X", ticket->trip_end_uic);
if(flipper_format_file_open_existing(file, file_name)) {
flipper_format_read_string(
file, furi_string_get_cstr(departure_uic), ticket->departure_name);
flipper_format_rewind(file);
flipper_format_read_string(
file, furi_string_get_cstr(destination_uic), ticket->destination_name);
flipper_format_rewind(file);
flipper_format_read_string(
file, furi_string_get_cstr(trip_start_uic), ticket->trip_start_sta_name);
flipper_format_rewind(file);
flipper_format_read_string(
file, furi_string_get_cstr(trip_end_uic), ticket->trip_end_sta_name);
}
flipper_format_free(file);
furi_string_free(departure_uic);
furi_string_free(destination_uic);
furi_string_free(trip_start_uic);
furi_string_free(trip_end_uic);
}
// Function to resolve station names for a ticket, and if not found, set to "1E" + UIC code
static void resolve_station_name(Storage* storage, TicketData* ticket) {
sz_uic_to_sta(storage, EXT_PATH("nfc/assets/sz_id.nfc"), ticket);
if(furi_string_utf8_length(ticket->departure_name) <= 2) {
furi_string_printf(ticket->departure_name, "1E%04X", ticket->departure_uic);
}
if(furi_string_utf8_length(ticket->destination_name) <= 2) {
furi_string_printf(ticket->destination_name, "1E%04X", ticket->destination_uic);
}
if(furi_string_utf8_length(ticket->trip_start_sta_name) <= 2) {
furi_string_printf(ticket->trip_start_sta_name, "1E%04X", ticket->trip_start_uic);
}
if(furi_string_utf8_length(ticket->trip_end_sta_name) <= 2) {
furi_string_printf(ticket->trip_end_sta_name, "1E%04X", ticket->trip_end_uic);
}
}
static inline void extract_ppk_data(
Storage* storage,
const MfClassicData* data,
TicketData* ticket,
bool ticket_number) {
if(ticket_number == 0) {
ticket->departure_uic = (data->block[FIRST_PPK_TICKET_OFFSET].data[6] << 8) |
(data->block[FIRST_PPK_TICKET_OFFSET].data[5]);
ticket->destination_uic = (data->block[FIRST_PPK_TICKET_OFFSET].data[9] << 8) |
(data->block[FIRST_PPK_TICKET_OFFSET].data[8]);
ticket->value_data = data->block[FIRST_TICKET_VALUE_BLOCK].data[0];
ticket->current_status = data->block[FIRST_TICKET_VALUE_BLOCK + 1].data[8];
ticket->valid_from_data = (data->block[FIRST_PPK_TICKET_OFFSET].data[2] << 8) |
(data->block[FIRST_PPK_TICKET_OFFSET].data[1]);
ticket->valid_till_data = (data->block[FIRST_PPK_TICKET_OFFSET].data[4] << 8) |
(data->block[FIRST_PPK_TICKET_OFFSET].data[3]);
ticket->tap_data = (data->block[FIRST_TICKET_VALUE_BLOCK + 1].data[2] << 16) |
(data->block[FIRST_TICKET_VALUE_BLOCK + 1].data[1] << 8) |
data->block[FIRST_TICKET_VALUE_BLOCK + 1].data[0];
ticket->ppk_cnt = data->block[FIRST_TICKET_VALUE_BLOCK + 1].data[10];
ticket->trip_start_uic = (data->block[FIRST_TICKET_VALUE_BLOCK + 1].data[4] << 8) |
(data->block[FIRST_TICKET_VALUE_BLOCK + 1].data[3]);
ticket->trip_end_uic = (data->block[FIRST_TICKET_VALUE_BLOCK + 1].data[7] << 8) |
(data->block[FIRST_TICKET_VALUE_BLOCK + 1].data[6]);
} else {
ticket->departure_uic = (data->block[SECOND_PPK_TICKET_OFFSET].data[6] << 8) |
(data->block[SECOND_PPK_TICKET_OFFSET].data[5]);
ticket->destination_uic = (data->block[SECOND_PPK_TICKET_OFFSET].data[9] << 8) |
(data->block[SECOND_PPK_TICKET_OFFSET].data[8]);
ticket->value_data = data->block[SECOND_TICKET_VALUE_BLOCK].data[0];
ticket->current_status = data->block[SECOND_TICKET_VALUE_BLOCK + 1].data[8];
ticket->valid_from_data = (data->block[SECOND_PPK_TICKET_OFFSET].data[2] << 8) |
(data->block[SECOND_PPK_TICKET_OFFSET].data[1]);
ticket->valid_till_data = (data->block[SECOND_PPK_TICKET_OFFSET].data[4] << 8) |
(data->block[SECOND_PPK_TICKET_OFFSET].data[3]);
ticket->tap_data = (data->block[SECOND_TICKET_VALUE_BLOCK + 1].data[2] << 16) |
(data->block[SECOND_TICKET_VALUE_BLOCK + 1].data[1] << 8) |
data->block[SECOND_TICKET_VALUE_BLOCK + 1].data[0];
ticket->ppk_cnt = data->block[SECOND_TICKET_VALUE_BLOCK + 1].data[10];
ticket->trip_start_uic = (data->block[SECOND_TICKET_VALUE_BLOCK + 1].data[4] << 8) |
(data->block[SECOND_TICKET_VALUE_BLOCK + 1].data[3]);
ticket->trip_end_uic = (data->block[SECOND_TICKET_VALUE_BLOCK + 1].data[7] << 8) |
(data->block[SECOND_TICKET_VALUE_BLOCK + 1].data[6]);
}
ticket->first_ticket_marker = data->block[FIRST_PPK_TICKET_OFFSET].data[0];
ticket->second_ticket_marker = data->block[SECOND_PPK_TICKET_OFFSET].data[0];
const uint32_t valid_from_timestamp =
PPK_WHOLE_EPOCH_START + ticket->valid_from_data * SECONDS_IN_A_DAY;
const uint32_t valid_till_timestamp =
PPK_WHOLE_EPOCH_START + ticket->valid_till_data * SECONDS_IN_A_DAY;
const uint32_t tap_timestamp =
PPK_CURRENT_EPOCH_START + ticket->tap_data * SECONDS_IN_A_MINUTE;
datetime_timestamp_to_datetime(valid_from_timestamp, &ticket->valid_from);
datetime_timestamp_to_datetime(valid_till_timestamp, &ticket->valid_till);
datetime_timestamp_to_datetime(tap_timestamp, &ticket->tap_time);
resolve_station_name(storage, ticket);
}
static inline bool is_accompany_card(uint16_t departure_uic, uint16_t destination_uic) {
return departure_uic == destination_uic;
}
static void printf_accompany_card(TicketData* ticket, FuriString* parsed_data) {
if(ticket->departure_uic == 0x0000) {
furi_string_cat_printf(
parsed_data,
"\e#Unknown SZPPK Card\n NO TICKET DATA FOUND \n\nTHE TICKET IS NOT ISSUED\nOR LAYOUT IS UNKNOWN\n");
return;
} else {
furi_string_cat_printf(
parsed_data,
"\e#SZPPK Accompany Card\nValid on: %02d-%02d-%04d\nStation: > %s\n",
ticket->valid_from.day,
ticket->valid_from.month,
ticket->valid_from.year,
furi_string_get_cstr(ticket->departure_name));
switch(ticket->current_status) {
case 0x00:
furi_string_cat_printf(
parsed_data, "Status:> NOT USED\nPPK CNT: %03d\n", ticket->ppk_cnt);
break;
case 0x80:
furi_string_cat_printf(
parsed_data,
"Status:> ENTERED STATION\nChecked in at:> %02d:%02d\nPPK CNT: %03d\n",
ticket->tap_time.hour,
ticket->tap_time.minute,
ticket->ppk_cnt);
break;
case 0x1E:
furi_string_cat_printf(
parsed_data,
"Status:> EXITED STATION\nChecked out at:> %02d:%02d\nPPK CNT: %03d\n",
ticket->tap_time.hour,
ticket->tap_time.minute,
ticket->ppk_cnt);
break;
default:
furi_string_cat_printf(
parsed_data, "Status:> UNKNOWN\nPPK CNT: %03d\n", ticket->ppk_cnt);
break;
}
}
}
static void
printf_transport_card(FuriString* parsed_data, TicketData* ticket, bool ticket_number) {
if(ticket->departure_uic == 0x0000) {
furi_string_cat_printf(
parsed_data,
"\e#Unknown SZPPK Card\n NO TICKET DATA FOUND \n\nTHE TICKET IS NOT ISSUED\nOR LAYOUT IS UNKNOWN\n");
return;
} else {
if(ticket_number == 0) {
furi_string_cat_printf(parsed_data, "\e#SZPPK Transport Card\n");
switch(ticket->first_ticket_marker) {
case 0x02:
furi_string_cat_printf(parsed_data, "Type:> 5 days unlim.");
break;
case 0x06:
furi_string_cat_printf(parsed_data, "Type:> 10 rides");
break;
case 0x18:
furi_string_cat_printf(parsed_data, "Type:> 30 days unlim.");
break;
case 0x1B:
furi_string_cat_printf(parsed_data, "Type:> 20 rides.");
break;
default:
furi_string_cat_printf(
parsed_data, "Type: Unknown, 0x%02X\n", ticket->first_ticket_marker);
}
} else {
furi_string_cat_printf(parsed_data, "\e#Second Ticket:\n");
switch(ticket->second_ticket_marker) {
case 0x02:
furi_string_cat_printf(parsed_data, "Type:> 5 days unlim.");
break;
case 0x06:
furi_string_cat_printf(parsed_data, "Type:> 10 rides");
break;
case 0x18:
furi_string_cat_printf(parsed_data, "Type:> 30 days unlim.");
break;
case 0x1B:
furi_string_cat_printf(parsed_data, "Type:> 20 rides.");
break;
default:
furi_string_cat_printf(
parsed_data, "Type: Unknown, 0x%02X", ticket->second_ticket_marker);
}
}
furi_string_cat_printf(
parsed_data,
"\nFrom:>%s\nTo:>%s\nValid From: %02d-%02d-%04d\nValid thru: %02d-%02d-%04d",
furi_string_get_cstr(ticket->departure_name),
furi_string_get_cstr(ticket->destination_name),
ticket->valid_from.day,
ticket->valid_from.month,
ticket->valid_from.year,
ticket->valid_till.day,
ticket->valid_till.month,
ticket->valid_till.year);
if(ticket->value_data > 0) {
furi_string_cat_printf(parsed_data, "Rides remain: %02d\n", ticket->value_data);
}
switch(ticket->current_status) {
case 0x00:
furi_string_cat_printf(
parsed_data, "Status:> NOT USED\nPPK CNT: %03d\n", ticket->ppk_cnt);
break;
case 0x80:
furi_string_cat_printf(
parsed_data,
"\nStatus:> ENTERED STATION\nSta name:> %s\nLast pass on:> %02d-%02d-%04d\nPass time:> %02d:%02d\nPPK CNT: %03d\n",
furi_string_get_cstr(ticket->trip_start_sta_name),
ticket->tap_time.day,
ticket->tap_time.month,
ticket->tap_time.year,
ticket->tap_time.hour,
ticket->tap_time.minute,
ticket->ppk_cnt);
break;
case 0x1E:
furi_string_cat_printf(
parsed_data,
"\nStatus:> EXITED STATION\nSta name:> %s\nLast pass on:> %02d-%02d-%04d\nPass time:> %02d:%02d\nPPK CNT: %03d\n",
furi_string_get_cstr(ticket->trip_end_sta_name),
ticket->tap_time.day,
ticket->tap_time.month,
ticket->tap_time.year,
ticket->tap_time.hour,
ticket->tap_time.minute,
ticket->ppk_cnt);
break;
default:
furi_string_cat_printf(
parsed_data,
"Status:> UNKNOWN (%02X)\nPPK CNT: %03d",
ticket->current_status,
ticket->ppk_cnt);
break;
}
}
}
bool szppk_so_verify(Nfc* nfc) {
const uint8_t verify_sector = 19;
const uint8_t block_num = mf_classic_get_first_block_num_of_sector(verify_sector);
MfClassicKey key = {};
bit_lib_num_to_bytes_be(so_card_2k[verify_sector].a, COUNT_OF(key.data), key.data);
MfClassicAuthContext auth_ctx = {};
MfClassicError error =
mf_classic_poller_sync_auth(nfc, block_num, &key, MfClassicKeyTypeA, &auth_ctx);
return error == MfClassicErrorNone;
}
static bool szppk_so_read(Nfc* nfc, NfcDevice* device) {
furi_assert(nfc);
furi_assert(device);
MfClassicData* data = mf_classic_alloc();
nfc_device_copy_data(device, NfcProtocolMfClassic, data);
bool is_read = false;
MfClassicType type = MfClassicType1k;
MfClassicError error = mf_classic_poller_sync_detect_type(nfc, &type);
if(error == MfClassicErrorNone) {
data->type = type;
MfClassicDeviceKeys keys = {};
for(size_t i = 0; i < 32; i++) {
bit_lib_num_to_bytes_be(so_card_2k[i].a, sizeof(MfClassicKey), keys.key_a[i].data);
FURI_BIT_SET(keys.key_a_mask, i);
bit_lib_num_to_bytes_be(so_card_2k[i].b, sizeof(MfClassicKey), keys.key_b[i].data);
FURI_BIT_SET(keys.key_b_mask, i);
}
error = mf_classic_poller_sync_read(nfc, &keys, data);
if(error != MfClassicErrorNotPresent) {
nfc_device_set_data(device, NfcProtocolMfClassic, data);
is_read = (error == MfClassicErrorNone);
}
}
mf_classic_free(data);
return is_read;
}
static bool szppk_so_parse(const NfcDevice* device, FuriString* parsed_data) {
furi_assert(device);
const MfClassicData* data = nfc_device_get_data(device, NfcProtocolMfClassic);
bool parsed = false;
do {
MfClassicSectorTrailer* sec_tr = mf_classic_get_sector_trailer_by_sector(data, 19);
uint64_t key = bit_lib_bytes_to_num_be(sec_tr->key_a.data, 6);
if(key != so_card_2k[19].a) break;
Storage* storage = furi_record_open(RECORD_STORAGE);
TicketData primary_ticket = {0};
primary_ticket.departure_name = furi_string_alloc();
primary_ticket.destination_name = furi_string_alloc();
primary_ticket.trip_start_sta_name = furi_string_alloc();
primary_ticket.trip_end_sta_name = furi_string_alloc();
extract_ppk_data(storage, data, &primary_ticket, 0);
if(is_accompany_card(primary_ticket.departure_uic, primary_ticket.destination_uic)) {
printf_accompany_card(&primary_ticket, parsed_data);
} else {
printf_transport_card(parsed_data, &primary_ticket, false);
}
furi_string_free(primary_ticket.departure_name);
furi_string_free(primary_ticket.destination_name);
furi_string_free(primary_ticket.trip_start_sta_name);
furi_string_free(primary_ticket.trip_end_sta_name);
if(primary_ticket.second_ticket_marker != 0) {
TicketData secondary_ticket = {0};
secondary_ticket.departure_name = furi_string_alloc();
secondary_ticket.destination_name = furi_string_alloc();
secondary_ticket.trip_start_sta_name = furi_string_alloc();
secondary_ticket.trip_end_sta_name = furi_string_alloc();
extract_ppk_data(storage, data, &secondary_ticket, 1);
printf_transport_card(parsed_data, &secondary_ticket, true);
furi_string_free(primary_ticket.departure_name);
furi_string_free(primary_ticket.destination_name);
furi_string_free(primary_ticket.trip_start_sta_name);
furi_string_free(primary_ticket.trip_end_sta_name);
}
furi_record_close(RECORD_STORAGE);
parsed = true;
} while(false);
return parsed;
}
static const NfcSupportedCardsPlugin szppk_so_plugin = {
.protocol = NfcProtocolMfClassic,
.verify = szppk_so_verify,
.read = szppk_so_read,
.parse = szppk_so_parse,
};
static const FlipperAppPluginDescriptor szppk_so_plugin_descriptor = {
.appid = NFC_SUPPORTED_CARD_PLUGIN_APP_ID,
.ep_api_version = NFC_SUPPORTED_CARD_PLUGIN_API_VERSION,
.entry_point = &szppk_so_plugin,
};
const FlipperAppPluginDescriptor* szppk_so_plugin_ep(void) {
return &szppk_so_plugin_descriptor;
}
@@ -39,7 +39,7 @@ bool two_cities_verify(Nfc* nfc) {
bool verified = false;
do {
const uint8_t verify_sector = 4;
const uint8_t verify_sector = 9;
uint8_t block_num = mf_classic_get_first_block_num_of_sector(verify_sector);
FURI_LOG_D(TAG, "Verifying sector %u", verify_sector);
@@ -0,0 +1,124 @@
Filetype: Flipper NFC resources
Version: 1
# staID: Name
9468: KULISHKI
9469: KRASNOE
968C: UGLICH
97A7: 40 KM
9C61: 29 KM
AB91: YAROSLAVL-GL.
AB92: YAROSLAVL-PASS
AB93: PRIVOLZHYE
AB94: FILINO
ABD5: 300 KM
ABFF: 4 KM
AC4D: SKALINO
AC4F: MARFINO
AC50: PRECHISTOE
AC51: MAKAROVO
AC53: PURSHEVO
AC54: RODIONOVO
AC55: MASLOVO
AC56: NEKOUZ
AC57: SHESTIKHINO
AC59: VOLGA
AC5A: KOBOSTOVO
AC5B: TIKHMENEVO
AC5C: LOM
AC5D: VAULOVO
AC5E: CHEBAKOVO
AC64: LYUTOVO
AC65: TOSCHIKHA
AC66: BURMAKINO
AC68: PANTELEEVO
AC69: PUTYATINO
AC6A: PUCHKOVSKIY
AC6B: UTKINO
AC6F: KOZMODEMNSK
AC70: KOROMYSLOVO
AC71: SEMIBRATOVO
AC72: ROSTOV-YARSLV.
AC73: PETROVSK
AC74: SIL'NITSY
AC75: ITLAR'
AC77: BEKLEMISHEVO
AC78: RYAZANTSEVO
AC79: SHUSHKOVO
AC7A: BERENDEEVO
AC8A: RYBINSK-PASS.
ACA6: SEKSHA
ACA7: LYUBIM
ACA9: ZHAROK
ACAA: SOT'
ACAB: LUNKA
ACD4: DANOLOV
ACE9: DUNAIKA
ACEA: LIPOVAYA GORA
AD03: 155 KM
AD04: 147 KM
AD06: 187 KM
AD07: 231 KM
AD08: 259 KM
AD09: 268 KM
AD0A: 274 KM
AD0B: DEPO
AD0C: 310 KM
AD0D: 322 KM
AD0E: 331 KM
AD0F: 343 KM
AD10: 351 KM
AD45: 361 KM
AD46: 370 KM
AD47: 378 KM
AD48: 388 KM
AD4A: 399 KM
AD4B: 409 KM
AD4C: 419 KM
AEB1: KOCHENYATINO
AEB3: BAGRIMOVO
AEB4: REKA
AEB5: SOSNOVTSY
AEB9: DOGADTSEVO
AEBD: KUDRYAVTSEVO
AEBF: PROSVET
AEC1: GAVROLOV YAM
AEC3: LEVTSOVO
AEC7: HOZHAEVO
AECA: TSYBRINO
AECB: ROKSHA
AEF4: 69 KM
AEF5: 56 KM
AEF6: 323 KM
AEFA: 287 KM
AEFB: 296 KM
AEFC: 305 KM
AEFD: 310 KM
AEFE: 318 KM
AF0E: 265 KM
AF0F: 285 KM
B057: MAKAROVSKAYA
B05A: USHAKOVO
B05F: VAREGOVO
B07A: 306 KM
B07B: 296 KM
B07C: YURINSKIY
B07D: TOROPOVO
B07E: PINYAGI
B07F: CHIZHOVO
B080: KLINTSEVO
B081: TENINO
B082: MOLOT
B083: TELISCHEVO
B084: SUHAREZH
B086: 324 KM
B087: POLYANKI
B08A: DEBOLOVSKAYA
B08B: 2 KM
B090: RUSHA
B0AC: KOTOROSL'
B0D1: 403 KM
B0D2: SHOLOSOVO
B0D3: MURINSKOE
B0D4: ONAN'JINO
B0D5: L'NOZAVOD
B0F3: 359 KM
@@ -0,0 +1,85 @@
Filetype: Flipper NFC resources
Version: 1
# staID: Name
8377: OP 1725 KM
8350: STEKOL'NYI
834F: SELHOZTECHNIKA
834D: KANGLY
834B: OP 1861 KM
834A: OP 1856 KM
8349: OP 1842 KM
8348: OP 1832 KM
8347: OP 1806 KM
8344: OP 44 KM
8343: OP 129 KM
8288: SUVOROVSKAYA
8188: NESKUCHNYI
8183: OP 1741 KM
8182: KOCHUBEEVSKII
817E: SURKUL
817D: OP 1823 KM
8177: TERSKII
8176: KRASNAYA
8175: TEPLAYA RECHKA
8165: DVORTSOVYI
8164: PIONER
8161: ORBEL'YANOVO
815F: DZHEMUHA
815D: ETOKA
8158: OBIL'NYI
8157: NINY
813C: STAVROPOL
8104: KARMALINOVSKII
8084: KRASN.DEREVNIA
807E: MIHAILOVSKAIA
7FF8: ZMEIKA
7FF6: MASHUK
7FF5: LERMONTOVSKII
7FF4: NOVOPIATIGORSK
7FF3: SKACHKI
7FF2: ZOLOTUSHKA
7FF1: BELYI UGOL'
7FF0: PODKUMOK
7FEF: MINUTKA
7FBA: PLAKSEIKA
7FB9: MASLOV KUT
7FB8: ZELENOKUMSK
7FB7: KUMA
7FB6: GEORGIEVSK
7FA6: VIAZNIKI
7FA4: YAGODKA
7FA1: MAIAK
7F9B: BEDENNOVSK
7F6A: PALAGIADA
7F69: RYZDVIANAIA
7F68: PEREDOVAIA
7F65: GRIGOROPOLISSK.
7F5C: NEVINNOMYSSK.
7F2B: INOZEMTSEVO
7F2A: MIN.VODY
7F1B: ZHELEZNOVODSK
7F17: BESHTAU
7EFD: RASSHEVATKA
7EBC: PIATIGORSK
7EB2: KISLOVODSK
7EA5: ZOLSKII
7EA4: VINOGRADNAIA
7EA2: KUMAGORSK
7E9F: NAGUTSKAIA
7E9D: KURSAVKA
7E9B: KIAN
7E9A: ZELENCHUK
7E96: BOGLOVSKAIA
7E95: OVECHKA
8162: KURSHAVA
7F97: CHUKOTSKII
83AE: DEBRI
8306: DEGTIAREVSKI
8185: NIVA
8160: OP 1814 KM
815B: CHISTAIA
7FA5: BERMEDSKII
7F93: IZOBILNAIA
7F64: KRASNOKUBANSK.
7ED0: ESSENTUKI
7EA6: APOLLONSKAIA
@@ -0,0 +1,151 @@
Filetype: Flipper NFC resources
Version: 1
# staID: Name
9426: LADOZH.VOKZ.
9421: MOSKOV.VOKZ.
9423: VITEBS.VOKZ
9424: FINLND.VOKZ
9425: BALT.VOKZ
948B: IM.MOROZOVA
94B4: TOSNO
94B5: SABLINO
94BE: GORY
94C0: IZHORY
94C1: RYBATSKOE
24C2: OBUKHOVO
94C3: SLAVYANKA
94C4: KOLPINO
94C9: MSHINSKAYA
94CC: STROGANOVO
94CD: SUI'DA
94CE: GATCHINA BLT.
94D4: VYRITSA
94D5: PAVLOVSK
94D6: TSARSKOE SELO
94E2: KALISHCHE
94E3: LEBYAZHYE
94E4: BOLSH.IZHORA
9507: BELOOSTROV
9508: ZELENOGORSK
9511: RUCH'I
9512: TOKSOVO
9513: OSEL'KI
9514: PERI
9515: GRUZINO
9516: VASKELOVO
9517: OREKHOVO
9518: PETYAJARVI
9519: LOSEVO
951A: GROMOVO
951F: KUZNECHNOE
95C3: KAPITOLOVO
969A: MGA
969C: LUGA
969D: TOLMACHEVO
969E: SIVERSKAYA
969F: GATCHINA VRSH.
96A1: OREDEZH
96A3: SLANTSY
96A5: ORANIENBAUM 1
96A8: VOLOSOVO
96BD: TIKHVIN
96C0: VOLKHOVSTROY 1
96C1: VOLKHOVSTROY 2
96CA: VYBORG
96CB: SOSNOVO
96CC: PRIOZYORSK
970A: BOLOTISTOE
971D: STAR.DEREVNYA
9721: 54 KM
9723: KOLOSKOVO(79KM)
972C: PARAVOZNY MUZEI
9741: BOROVAYA
97BB: DETSKOSELSKAYA
9809: KUPCHINO
9810: PETROKREPOST'
9811: ALEKSANDROVSK.F
9817: BERNGARDOVKA
981C: PROSPEKT SLAVY
9826: SOSNOVAYA POL.
9829: PUDOST'
982B: LENINSKI' PR.
982D: PAVLOVO-NA-NEVE
9835: LANSKAYA
9838: KANNELYARVI
983A: MELN.RUCHEI
983F: PISKARYOVKA
9849: UDEL'NAYA
9852: BRONKA
9853: VSEVOLOZHSK.
985D: FARFOROVSKAYA
9867: RZHEVKA
9868: KOBRALOVO
986B: AEROPORT
9871: BRONEVAYA
987B: SESTRORETSK
9880: TATIANINO
988A: KUSHELEVKA
9890: RAKH'YA
9894: UL'YANKA
9899: PESOCHNAYA
989A: NAVALOCHNAYA
989E: STREL'NA
98A3: ST.PETERGOF
98A5: KOLTUSHI
98A8: KUZ'MOLOVO
98AD: KRASN. SELO
98AE: SHUSHARY
98BC: GORELOVO
98C1: ROSHCHINO
98CB: LISII' NOS
98D5: DACHNOYE
98D6: PREDPORTOVAYA
98E4: NOVAYA OKHTA
98E9: REPINO
98F3: LEVASHOVO
98FD: NOV.DEREVNYA
98FE: IZHORSK.ZAVOD
9908: VAGANOVO
990C: SHUVALOVO
9911: DUNAI'
9916: IRINOVKA
991C: TARKHOVKA
9923: PUPYSHEVO
9925: TAI'TSY
9926: LAVRIKI
992A: LADOZHSK.OZ.
992D: ROMANOVKA
9936: RAZLIV
9938: KIRILLOVSKOE
9939: OL'GINO
993D: METALLOSTROY
993E: KAVGOLOVO
9943: VOZD.PARK
9944: POST KOVALEVO
9949: LAPPELOVO
994D: KORNEVO
9952: SOLNECHNOYE
9956: MYAGLOVO
995B: OZERKI
995C: KOMAROVO
9969: PROBA
996A: KIRPICH.ZAVOD
9970: KURORT
9975: 67 KM
9976: USHKOVO
9981: DUDERGOF
9989: UNIVERSITETSK.
9992: YAKHTENNAYA
9996: KOVALEVO
9997: DIBUNY
99AF: MANUSHKINO
99CF: PARGOLOVO
99D4: LIGOVO
9C09: DEVYATKINO
9C58: NEV.DUBROVKA
9C5A: ALEKSANDROVSK.
9C5B: GORSKAYA
9D1C: NOV.PETERGOF
9FDC: BORISOVA GRIVA
98B7: SERGIEVO
9957: LAKHTA