clipper.c 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710
  1. /*
  2. * clipper.c - Parser for Clipper cards (San Francisco, California).
  3. *
  4. * Based on research, some of which dates to 2007!
  5. *
  6. * Copyright 2024 Jeremy Cooper <jeremy.gthb@baymoo.org>
  7. *
  8. * This program is free software: you can redistribute it and/or modify it
  9. * under the terms of the GNU General Public License as published by
  10. * the Free Software Foundation, either version 3 of the License, or
  11. * (at your option) any later version.
  12. *
  13. * This program is distributed in the hope that it will be useful, but
  14. * WITHOUT ANY WARRANTY; without even the implied warranty of
  15. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
  16. * General Public License for more details.
  17. *
  18. * You should have received a copy of the GNU General Public License
  19. * along with this program. If not, see <http://www.gnu.org/licenses/>.
  20. */
  21. #include <flipper_application.h>
  22. #include "../../metroflip_i.h"
  23. #include <nfc/protocols/mf_desfire/mf_desfire_poller.h>
  24. #include <lib/nfc/protocols/mf_desfire/mf_desfire.h>
  25. #include <bit_lib.h>
  26. #include <datetime.h>
  27. #include <locale/locale.h>
  28. #include <inttypes.h>
  29. #include "../../api/metroflip/metroflip_api.h"
  30. #include "../../metroflip_plugins.h"
  31. #define TAG "Metroflip:Scene:Clipper"
  32. //
  33. // Table of application ids observed in the wild, and their sources.
  34. //
  35. static const struct {
  36. const MfDesfireApplicationId app;
  37. const char* type;
  38. } clipper_types[] = {
  39. // Application advertised on classic, plastic cards.
  40. {.app = {.data = {0x90, 0x11, 0xf2}}, .type = "Card"},
  41. // Application advertised on a mobile device.
  42. {.app = {.data = {0x91, 0x11, 0xf2}}, .type = "Mobile Device"},
  43. };
  44. static const size_t kNumCardTypes = sizeof(clipper_types) / sizeof(clipper_types[0]);
  45. struct IdMapping_struct {
  46. uint16_t id;
  47. const char* name;
  48. };
  49. typedef struct IdMapping_struct IdMapping;
  50. #define COUNT(_array) sizeof(_array) / sizeof(_array[0])
  51. //
  52. // Known transportation agencies and their identifiers.
  53. //
  54. static const IdMapping agency_names[] = {
  55. {.id = 0x0001, .name = "AC Transit"},
  56. {.id = 0x0004, .name = "BART"},
  57. {.id = 0x0006, .name = "Caltrain"},
  58. {.id = 0x0008, .name = "CCTA"},
  59. {.id = 0x000b, .name = "GGT"},
  60. {.id = 0x000f, .name = "SamTrans"},
  61. {.id = 0x0011, .name = "VTA"},
  62. {.id = 0x0012, .name = "Muni"},
  63. {.id = 0x0019, .name = "GG Ferry"},
  64. {.id = 0x001b, .name = "SF Bay Ferry"},
  65. };
  66. static const size_t kNumAgencies = COUNT(agency_names);
  67. //
  68. // Known station names for various agencies.
  69. //
  70. static const IdMapping bart_zones[] = {
  71. {.id = 0x0001, .name = "Colma"},
  72. {.id = 0x0002, .name = "Daly City"},
  73. {.id = 0x0003, .name = "Balboa Park"},
  74. {.id = 0x0004, .name = "Glen Park"},
  75. {.id = 0x0005, .name = "24th St Mission"},
  76. {.id = 0x0006, .name = "16th St Mission"},
  77. {.id = 0x0007, .name = "Civic Center/UN Plaza"},
  78. {.id = 0x0008, .name = "Powell St"},
  79. {.id = 0x0009, .name = "Montgomery St"},
  80. {.id = 0x000a, .name = "Embarcadero"},
  81. {.id = 0x000b, .name = "West Oakland"},
  82. {.id = 0x000c, .name = "12th St/Oakland City Center"},
  83. {.id = 0x000d, .name = "19th St/Oakland"},
  84. {.id = 0x000e, .name = "MacArthur"},
  85. {.id = 0x000f, .name = "Rockridge"},
  86. {.id = 0x0010, .name = "Orinda"},
  87. {.id = 0x0011, .name = "Lafayette"},
  88. {.id = 0x0012, .name = "Walnut Creek"},
  89. {.id = 0x0013, .name = "Pleasant Hill/Contra Costa Centre"},
  90. {.id = 0x0014, .name = "Concord"},
  91. {.id = 0x0015, .name = "North Concord/Martinez"},
  92. {.id = 0x0016, .name = "Pittsburg/Bay Point"},
  93. {.id = 0x0017, .name = "Ashby"},
  94. {.id = 0x0018, .name = "Downtown Berkeley"},
  95. {.id = 0x0019, .name = "North Berkeley"},
  96. {.id = 0x001a, .name = "El Cerrito Plaza"},
  97. {.id = 0x001b, .name = "El Cerrito Del Norte"},
  98. {.id = 0x001c, .name = "Richmond"},
  99. {.id = 0x001d, .name = "Lake Merrit"},
  100. {.id = 0x001e, .name = "Fruitvale"},
  101. {.id = 0x001f, .name = "Coliseum"},
  102. {.id = 0x0021, .name = "San Leandro"},
  103. {.id = 0x0022, .name = "Hayward"},
  104. {.id = 0x0023, .name = "South Hayward"},
  105. {.id = 0x0024, .name = "Union City"},
  106. {.id = 0x0025, .name = "Fremont"},
  107. {.id = 0x0026, .name = "Castro Valley"},
  108. {.id = 0x0027, .name = "Dublin/Pleasanton"},
  109. {.id = 0x0028, .name = "South San Francisco"},
  110. {.id = 0x0029, .name = "San Bruno"},
  111. {.id = 0x002a, .name = "SFO Airport"},
  112. {.id = 0x002b, .name = "Millbrae"},
  113. {.id = 0x002c, .name = "West Dublin/Pleasanton"},
  114. {.id = 0x002d, .name = "OAK Airport"},
  115. {.id = 0x002e, .name = "Warm Springs/South Fremont"},
  116. {.id = 0x002f, .name = "Milpitas"},
  117. {.id = 0x0030, .name = "Berryessa/North San Jose"},
  118. };
  119. static const size_t kNumBARTZones = COUNT(bart_zones);
  120. static const IdMapping muni_zones[] = {
  121. {.id = 0x0000, .name = "City Street"},
  122. {.id = 0x0005, .name = "Embarcadero"},
  123. {.id = 0x0006, .name = "Montgomery"},
  124. {.id = 0x0007, .name = "Powell"},
  125. {.id = 0x0008, .name = "Civic Center"},
  126. {.id = 0x0009, .name = "Van Ness"}, // Guessed
  127. {.id = 0x000a, .name = "Church"},
  128. {.id = 0x000b, .name = "Castro"},
  129. {.id = 0x000c, .name = "Forest Hill"}, // Guessed
  130. {.id = 0x000d, .name = "West Portal"},
  131. };
  132. static const size_t kNumMUNIZones = COUNT(muni_zones);
  133. static const IdMapping actransit_zones[] = {
  134. {.id = 0x0000, .name = "City Street"},
  135. };
  136. static const size_t kNumACTransitZones = COUNT(actransit_zones);
  137. // Instead of persisting individual Station IDs, Caltrain saves Zone numbers.
  138. // https://www.caltrain.com/stations-zones
  139. static const IdMapping caltrain_zones[] = {
  140. {.id = 0x0001, .name = "Zone 1"},
  141. {.id = 0x0002, .name = "Zone 2"},
  142. {.id = 0x0003, .name = "Zone 3"},
  143. {.id = 0x0004, .name = "Zone 4"},
  144. {.id = 0x0005, .name = "Zone 5"},
  145. {.id = 0x0006, .name = "Zone 6"},
  146. };
  147. static const size_t kNumCaltrainZones = COUNT(caltrain_zones);
  148. //
  149. // Full agency+zone mapping.
  150. //
  151. static const struct {
  152. uint16_t agency_id;
  153. const IdMapping* zone_map;
  154. size_t zone_count;
  155. } agency_zone_map[] = {
  156. {.agency_id = 0x0001, .zone_map = actransit_zones, .zone_count = kNumACTransitZones},
  157. {.agency_id = 0x0004, .zone_map = bart_zones, .zone_count = kNumBARTZones},
  158. {.agency_id = 0x0006, .zone_map = caltrain_zones, .zone_count = kNumCaltrainZones},
  159. {.agency_id = 0x0012, .zone_map = muni_zones, .zone_count = kNumMUNIZones}};
  160. static const size_t kNumAgencyZoneMaps = COUNT(agency_zone_map);
  161. // File ids of important files on the card.
  162. static const MfDesfireFileId clipper_ecash_file_id = 2;
  163. static const MfDesfireFileId clipper_histidx_file_id = 6;
  164. static const MfDesfireFileId clipper_identity_file_id = 8;
  165. static const MfDesfireFileId clipper_history_file_id = 14;
  166. struct ClipperCardInfo_struct {
  167. uint32_t serial_number;
  168. uint16_t counter;
  169. uint16_t last_txn_id;
  170. uint32_t last_updated_tm_1900;
  171. uint16_t last_terminal_id;
  172. int16_t balance_cents;
  173. };
  174. typedef struct ClipperCardInfo_struct ClipperCardInfo;
  175. // Forward declarations for helper functions.
  176. static void furi_string_cat_timestamp(
  177. FuriString* str,
  178. const char* date_hdr,
  179. const char* time_hdr,
  180. uint32_t tmst_1900);
  181. static bool get_file_contents(
  182. const MfDesfireApplication* app,
  183. const MfDesfireFileId* id,
  184. MfDesfireFileType type,
  185. size_t min_size,
  186. const uint8_t** out);
  187. static bool decode_id_file(const uint8_t* ef8_data, ClipperCardInfo* info);
  188. static bool decode_cash_file(const uint8_t* ef2_data, ClipperCardInfo* info);
  189. static bool get_map_item(uint16_t id, const IdMapping* map, size_t sz, const char** out);
  190. static bool get_agency_zone_name(uint16_t agency_id, uint16_t zone_id, const char** out);
  191. static void
  192. decode_usd(int16_t amount_cents, bool* out_is_negative, int16_t* out_usd, uint16_t* out_cents);
  193. static bool dump_ride_history(
  194. const uint8_t* index_file,
  195. const uint8_t* history_file,
  196. size_t len,
  197. FuriString* parsed_data);
  198. static bool dump_ride_event(const uint8_t* record, FuriString* parsed_data);
  199. // Unmarshal a 32-bit integer, big endian, unsigned
  200. static inline uint32_t get_u32be(const uint8_t* field) {
  201. return bit_lib_bytes_to_num_be(field, 4);
  202. }
  203. // Unmarshal a 16-bit integer, big endian, unsigned
  204. static uint16_t get_u16be(const uint8_t* field) {
  205. return bit_lib_bytes_to_num_be(field, 2);
  206. }
  207. // Unmarshal a 16-bit integer, big endian, signed, two's-complement
  208. static int16_t get_i16be(const uint8_t* field) {
  209. uint16_t raw = get_u16be(field);
  210. if(raw > 0x7fff)
  211. return -((uint32_t)0x10000 - raw);
  212. else
  213. return raw;
  214. }
  215. bool clipper_parse(const MfDesfireData* data, FuriString* parsed_data) {
  216. furi_assert(parsed_data);
  217. bool parsed = false;
  218. do {
  219. const MfDesfireApplication* app = NULL;
  220. const char* device_description = NULL;
  221. for(size_t i = 0; i < kNumCardTypes; i++) {
  222. app = mf_desfire_get_application(data, &clipper_types[i].app);
  223. device_description = clipper_types[i].type;
  224. if(app != NULL) break;
  225. }
  226. // If no matching application was found, abort this parser.
  227. if(app == NULL) break;
  228. ClipperCardInfo info;
  229. const uint8_t* id_data;
  230. if(!get_file_contents(
  231. app, &clipper_identity_file_id, MfDesfireFileTypeStandard, 5, &id_data))
  232. break;
  233. if(!decode_id_file(id_data, &info)) break;
  234. const uint8_t* cash_data;
  235. if(!get_file_contents(app, &clipper_ecash_file_id, MfDesfireFileTypeBackup, 32, &cash_data))
  236. break;
  237. if(!decode_cash_file(cash_data, &info)) break;
  238. int16_t balance_usd;
  239. uint16_t balance_cents;
  240. bool _balance_is_negative;
  241. decode_usd(info.balance_cents, &_balance_is_negative, &balance_usd, &balance_cents);
  242. furi_string_cat_printf(
  243. parsed_data,
  244. "\e#Clipper\n"
  245. "Serial: %" PRIu32 "\n"
  246. "Balance: $%d.%02u\n"
  247. "Type: %s\n"
  248. "\e#Last Update\n",
  249. info.serial_number,
  250. balance_usd,
  251. balance_cents,
  252. device_description);
  253. if(info.last_updated_tm_1900 != 0)
  254. furi_string_cat_timestamp(
  255. parsed_data, "Date: ", "\nTime: ", info.last_updated_tm_1900);
  256. else
  257. furi_string_cat_str(parsed_data, "Never");
  258. furi_string_cat_printf(
  259. parsed_data,
  260. "\nTerminal: 0x%04x\n"
  261. "Transaction Id: %u\n"
  262. "Counter: %u\n",
  263. info.last_terminal_id,
  264. info.last_txn_id,
  265. info.counter);
  266. const uint8_t *history_index, *history;
  267. if(!get_file_contents(
  268. app, &clipper_histidx_file_id, MfDesfireFileTypeBackup, 16, &history_index))
  269. break;
  270. if(!get_file_contents(
  271. app, &clipper_history_file_id, MfDesfireFileTypeStandard, 512, &history))
  272. break;
  273. if(!dump_ride_history(history_index, history, 512, parsed_data)) break;
  274. parsed = true;
  275. } while(false);
  276. return parsed;
  277. }
  278. static bool get_file_contents(
  279. const MfDesfireApplication* app,
  280. const MfDesfireFileId* id,
  281. MfDesfireFileType type,
  282. size_t min_size,
  283. const uint8_t** out) {
  284. const MfDesfireFileSettings* settings = mf_desfire_get_file_settings(app, id);
  285. if(settings == NULL) return false;
  286. if(settings->type != type) return false;
  287. const MfDesfireFileData* file_data = mf_desfire_get_file_data(app, id);
  288. if(file_data == NULL) return false;
  289. if(simple_array_get_count(file_data->data) < min_size) return false;
  290. *out = simple_array_cget_data(file_data->data);
  291. return true;
  292. }
  293. static bool decode_id_file(const uint8_t* ef8_data, ClipperCardInfo* info) {
  294. // Identity file (8)
  295. //
  296. // Byte view
  297. //
  298. // 0 1 2 3 4 5 6 7 8
  299. // +----+----.----.----.----+----.----.----+
  300. // 0x00 | uk | card_id | unknown |
  301. // +----+----.----.----.----+----.----.----+
  302. // 0x08 | unknown |
  303. // +----.----.----.----.----.----.----.----+
  304. // 0x10 ...
  305. //
  306. //
  307. // Field Datatype Description
  308. // ----- -------- -----------
  309. // uk ?8?? Unknown, 8-bit byte
  310. // card_id U32BE Card identifier
  311. //
  312. info->serial_number = bit_lib_bytes_to_num_be(&ef8_data[1], 4);
  313. return true;
  314. }
  315. static bool decode_cash_file(const uint8_t* ef2_data, ClipperCardInfo* info) {
  316. // ECash file (2)
  317. //
  318. // Byte view
  319. //
  320. // 0 1 2 3 4 5 6 7 8
  321. // +----.----+----.----+----.----.----.----+
  322. // 0x00 | unk00 | counter | timestamp_1900 |
  323. // +----.----+----.----+----.----.----.----+
  324. // 0x08 | term_id | unk01 |
  325. // +----.----+----.----+----.----.----.----+
  326. // 0x10 | txn_id | balance | unknown |
  327. // +----.----+----.----+----.----.----.----+
  328. // 0x18 | unknown |
  329. // +---------------------------------------+
  330. //
  331. // Field Datatype Description
  332. // ----- -------- -----------
  333. // unk00 U8[2] Unknown bytes
  334. // counter U16BE Unknown, appears to be a counter
  335. // timestamp_1900 U32BE Timestamp of last transaction, in seconds
  336. // since 1900-01-01 GMT.
  337. // unk01 U8[6] Unknown bytes
  338. // txn_id U16BE Id of last transaction.
  339. // balance S16BE Card cash balance, in cents.
  340. // Cards can obtain negative balances in this
  341. // system, so balances are signed integers.
  342. // Maximum card balance is therefore
  343. // $327.67.
  344. // unk02 U8[12] Unknown bytes.
  345. //
  346. info->counter = get_u16be(&ef2_data[2]);
  347. info->last_updated_tm_1900 = get_u32be(&ef2_data[4]);
  348. info->last_terminal_id = get_u16be(&ef2_data[8]);
  349. info->last_txn_id = get_u16be(&ef2_data[0x10]);
  350. info->balance_cents = get_i16be(&ef2_data[0x12]);
  351. return true;
  352. }
  353. static bool dump_ride_history(
  354. const uint8_t* index_file,
  355. const uint8_t* history_file,
  356. size_t len,
  357. FuriString* parsed_data) {
  358. static const size_t kRideRecordSize = 0x20;
  359. for(size_t i = 0; i < 16; i++) {
  360. uint8_t record_num = index_file[i];
  361. if(record_num == 0xff) break;
  362. size_t record_offset = record_num * kRideRecordSize;
  363. if(record_offset + kRideRecordSize > len) break;
  364. const uint8_t* record = &history_file[record_offset];
  365. if(!dump_ride_event(record, parsed_data)) break;
  366. }
  367. return true;
  368. }
  369. static bool dump_ride_event(const uint8_t* record, FuriString* parsed_data) {
  370. // Ride record
  371. //
  372. // 0 1 2 3 4 5 6 7 8
  373. // +----+----+----.----+----.----+----.----+
  374. // 0x00 |0x10| ? | agency | ? | fare |
  375. // +----.----+----.----+----.----.----.----+
  376. // 0x08 | ? | vehicle | time_on |
  377. // +----.----.----.----+----.----+----.----+
  378. // 0x10 | time_off | zone_on | zone_off|
  379. // +----+----.----.----.----+----+----+----+
  380. // 0x18 | ? | ? | ? | ? | ? |
  381. // +----+----.----.----.----+----+----+----+
  382. //
  383. // Field Datatype Description
  384. // ----- -------- -----------
  385. // agency U16BE Transportation agency identifier.
  386. // Known ids:
  387. // 1 == AC Transit
  388. // 4 == BART
  389. // 18 == SF MUNI
  390. // fare I16BE Fare deducted, in cents.
  391. // vehicle U16BE Vehicle id (0 == not provided)
  392. // time_on U32BE Boarding time, in seconds since 1900-01-01 GMT.
  393. // time_off U32BE Off-boarding time, if present, in seconds
  394. // since 1900-01-01 GMT. Set to zero if no offboard
  395. // has been recorded.
  396. // zone_on U16BE Id of boarding zone or station. Agency-specific.
  397. // zone_off U16BE Id of offboarding zone or station. Agency-
  398. // specific.
  399. if(record[0] != 0x10) return false;
  400. uint16_t agency_id = get_u16be(&record[2]);
  401. if(agency_id == 0)
  402. // Likely empty record. Skip.
  403. return false;
  404. const char* agency_name;
  405. bool ok = get_map_item(agency_id, agency_names, kNumAgencies, &agency_name);
  406. if(!ok) agency_name = "Unknown";
  407. uint16_t vehicle_id = get_u16be(&record[0x0a]);
  408. int16_t fare_raw_cents = get_i16be(&record[6]);
  409. bool _fare_is_negative;
  410. int16_t fare_usd;
  411. uint16_t fare_cents;
  412. decode_usd(fare_raw_cents, &_fare_is_negative, &fare_usd, &fare_cents);
  413. uint32_t time_on_raw = get_u32be(&record[0x0c]);
  414. uint32_t time_off_raw = get_u32be(&record[0x10]);
  415. uint16_t zone_id_on = get_u16be(&record[0x14]);
  416. uint16_t zone_id_off = get_u16be(&record[0x16]);
  417. const char *zone_on, *zone_off;
  418. if(!get_agency_zone_name(agency_id, zone_id_on, &zone_on)) {
  419. zone_on = "Unknown";
  420. }
  421. if(!get_agency_zone_name(agency_id, zone_id_off, &zone_off)) {
  422. zone_off = "Unknown";
  423. }
  424. furi_string_cat_str(parsed_data, "\e#Ride Record\n");
  425. furi_string_cat_timestamp(parsed_data, "Date: ", "\nTime: ", time_on_raw);
  426. furi_string_cat_printf(
  427. parsed_data,
  428. "\n"
  429. "Fare: $%d.%02u\n"
  430. "Agency: %s (%04x)\n"
  431. "On: %s (%04x)\n",
  432. fare_usd,
  433. fare_cents,
  434. agency_name,
  435. agency_id,
  436. zone_on,
  437. zone_id_on);
  438. if(vehicle_id != 0) {
  439. furi_string_cat_printf(parsed_data, "Vehicle id: %d\n", vehicle_id);
  440. }
  441. if(time_off_raw != 0) {
  442. furi_string_cat_printf(parsed_data, "Off: %s (%04x)\n", zone_off, zone_id_off);
  443. furi_string_cat_timestamp(parsed_data, "Date Off: ", "\nTime Off: ", time_off_raw);
  444. furi_string_cat_str(parsed_data, "\n");
  445. }
  446. return true;
  447. }
  448. static bool get_map_item(uint16_t id, const IdMapping* map, size_t sz, const char** out) {
  449. for(size_t i = 0; i < sz; i++) {
  450. if(map[i].id == id) {
  451. *out = map[i].name;
  452. return true;
  453. }
  454. }
  455. return false;
  456. }
  457. static bool get_agency_zone_name(uint16_t agency_id, uint16_t zone_id, const char** out) {
  458. for(size_t i = 0; i < kNumAgencyZoneMaps; i++) {
  459. if(agency_zone_map[i].agency_id == agency_id) {
  460. return get_map_item(
  461. zone_id, agency_zone_map[i].zone_map, agency_zone_map[i].zone_count, out);
  462. }
  463. }
  464. return false;
  465. }
  466. // Split a balance/fare amount from raw cents to dollars and cents portion,
  467. // automatically adjusting the cents portion so that it is always positive,
  468. // for easier display.
  469. static void
  470. decode_usd(int16_t amount_cents, bool* out_is_negative, int16_t* out_usd, uint16_t* out_cents) {
  471. *out_usd = amount_cents / 100;
  472. if(amount_cents >= 0) {
  473. *out_is_negative = false;
  474. *out_cents = amount_cents % 100;
  475. } else {
  476. *out_is_negative = true;
  477. *out_cents = (amount_cents * -1) % 100;
  478. }
  479. }
  480. // Decode a raw 1900-based timestamp and append a human-readable form to a
  481. // FuriString.
  482. static void furi_string_cat_timestamp(
  483. FuriString* str,
  484. const char* date_hdr,
  485. const char* time_hdr,
  486. uint32_t tmst_1900) {
  487. DateTime tm;
  488. datetime_timestamp_to_datetime(tmst_1900, &tm);
  489. FuriString* date_str = furi_string_alloc();
  490. locale_format_date(date_str, &tm, locale_get_date_format(), "-");
  491. FuriString* time_str = furi_string_alloc();
  492. locale_format_time(time_str, &tm, locale_get_time_format(), true);
  493. furi_string_cat_printf(
  494. str,
  495. "%s%s%s%s (UTC)",
  496. date_hdr,
  497. furi_string_get_cstr(date_str),
  498. time_hdr,
  499. furi_string_get_cstr(time_str));
  500. furi_string_free(date_str);
  501. furi_string_free(time_str);
  502. }
  503. static NfcCommand clipper_poller_callback(NfcGenericEvent event, void* context) {
  504. furi_assert(event.protocol == NfcProtocolMfDesfire);
  505. Metroflip* app = context;
  506. NfcCommand command = NfcCommandContinue;
  507. FuriString* parsed_data = furi_string_alloc();
  508. Widget* widget = app->widget;
  509. furi_string_reset(app->text_box_store);
  510. const MfDesfirePollerEvent* mf_desfire_event = event.event_data;
  511. if(mf_desfire_event->type == MfDesfirePollerEventTypeReadSuccess) {
  512. nfc_device_set_data(
  513. app->nfc_device, NfcProtocolMfDesfire, nfc_poller_get_data(app->poller));
  514. const MfDesfireData* data = nfc_device_get_data(app->nfc_device, NfcProtocolMfDesfire);
  515. if(!clipper_parse(data, parsed_data)) {
  516. furi_string_reset(app->text_box_store);
  517. FURI_LOG_I(TAG, "Unknown card type");
  518. furi_string_printf(parsed_data, "\e#Unknown card\n");
  519. }
  520. widget_add_text_scroll_element(widget, 0, 0, 128, 64, furi_string_get_cstr(parsed_data));
  521. widget_add_button_element(
  522. widget, GuiButtonTypeRight, "Exit", metroflip_exit_widget_callback, app);
  523. widget_add_button_element(
  524. widget, GuiButtonTypeCenter, "Save", metroflip_save_widget_callback, app);
  525. furi_string_free(parsed_data);
  526. view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewWidget);
  527. metroflip_app_blink_stop(app);
  528. command = NfcCommandStop;
  529. } else if(mf_desfire_event->type == MfDesfirePollerEventTypeReadFailed) {
  530. view_dispatcher_send_custom_event(app->view_dispatcher, MetroflipCustomEventPollerSuccess);
  531. command = NfcCommandContinue;
  532. }
  533. return command;
  534. }
  535. static void clipper_on_enter(Metroflip* app) {
  536. dolphin_deed(DolphinDeedNfcRead);
  537. if(app->data_loaded) {
  538. Storage* storage = furi_record_open(RECORD_STORAGE);
  539. FlipperFormat* ff = flipper_format_file_alloc(storage);
  540. if(flipper_format_file_open_existing(ff, app->file_path)) {
  541. MfDesfireData* data = mf_desfire_alloc();
  542. mf_desfire_load(data, ff, 2);
  543. FuriString* parsed_data = furi_string_alloc();
  544. Widget* widget = app->widget;
  545. furi_string_reset(app->text_box_store);
  546. if(!clipper_parse(data, parsed_data)) {
  547. furi_string_reset(app->text_box_store);
  548. FURI_LOG_I(TAG, "Unknown card type");
  549. furi_string_printf(parsed_data, "\e#Unknown card\n");
  550. }
  551. widget_add_text_scroll_element(
  552. widget, 0, 0, 128, 64, furi_string_get_cstr(parsed_data));
  553. widget_add_button_element(
  554. widget, GuiButtonTypeRight, "Exit", metroflip_exit_widget_callback, app);
  555. widget_add_button_element(
  556. widget, GuiButtonTypeCenter, "Delete", metroflip_delete_widget_callback, app);
  557. mf_desfire_free(data);
  558. furi_string_free(parsed_data);
  559. view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewWidget);
  560. }
  561. flipper_format_free(ff);
  562. } else {
  563. // Setup view
  564. Popup* popup = app->popup;
  565. popup_set_header(popup, "Apply\n card to\nthe back", 68, 30, AlignLeft, AlignTop);
  566. popup_set_icon(popup, 0, 3, &I_RFIDDolphinReceive_97x61);
  567. // Start worker
  568. view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewPopup);
  569. nfc_scanner_alloc(app->nfc);
  570. app->poller = nfc_poller_alloc(app->nfc, NfcProtocolMfDesfire);
  571. nfc_poller_start(app->poller, clipper_poller_callback, app);
  572. metroflip_app_blink_start(app);
  573. }
  574. }
  575. static bool clipper_on_event(Metroflip* app, SceneManagerEvent event) {
  576. bool consumed = false;
  577. if(event.type == SceneManagerEventTypeCustom) {
  578. if(event.event == MetroflipCustomEventCardDetected) {
  579. Popup* popup = app->popup;
  580. popup_set_header(popup, "DON'T\nMOVE", 68, 30, AlignLeft, AlignTop);
  581. consumed = true;
  582. } else if(event.event == MetroflipCustomEventCardLost) {
  583. Popup* popup = app->popup;
  584. popup_set_header(popup, "Card \n lost", 68, 30, AlignLeft, AlignTop);
  585. consumed = true;
  586. } else if(event.event == MetroflipCustomEventWrongCard) {
  587. Popup* popup = app->popup;
  588. popup_set_header(popup, "WRONG \n CARD", 68, 30, AlignLeft, AlignTop);
  589. consumed = true;
  590. } else if(event.event == MetroflipCustomEventPollerFail) {
  591. Popup* popup = app->popup;
  592. popup_set_header(popup, "Failed", 68, 30, AlignLeft, AlignTop);
  593. consumed = true;
  594. }
  595. } else if(event.type == SceneManagerEventTypeBack) {
  596. scene_manager_search_and_switch_to_previous_scene(app->scene_manager, MetroflipSceneStart);
  597. scene_manager_set_scene_state(app->scene_manager, MetroflipSceneStart, MetroflipSceneAuto);
  598. consumed = true;
  599. }
  600. return consumed;
  601. }
  602. static void clipper_on_exit(Metroflip* app) {
  603. widget_reset(app->widget);
  604. metroflip_app_blink_stop(app);
  605. if(app->poller && !app->data_loaded) {
  606. nfc_poller_stop(app->poller);
  607. nfc_poller_free(app->poller);
  608. }
  609. }
  610. /* Actual implementation of app<>plugin interface */
  611. static const MetroflipPlugin clipper_plugin = {
  612. .card_name = "Clipper",
  613. .plugin_on_enter = clipper_on_enter,
  614. .plugin_on_event = clipper_on_event,
  615. .plugin_on_exit = clipper_on_exit,
  616. };
  617. /* Plugin descriptor to comply with basic plugin specification */
  618. static const FlipperAppPluginDescriptor clipper_plugin_descriptor = {
  619. .appid = METROFLIP_SUPPORTED_CARD_PLUGIN_APP_ID,
  620. .ep_api_version = METROFLIP_SUPPORTED_CARD_PLUGIN_API_VERSION,
  621. .entry_point = &clipper_plugin,
  622. };
  623. /* Plugin entry point - must return a pointer to const descriptor */
  624. const FlipperAppPluginDescriptor* clipper_plugin_ep(void) {
  625. return &clipper_plugin_descriptor;
  626. }