metroflip_scene_opal.c 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309
  1. /*
  2. * opal.c - Parser for Opal card (Sydney, Australia).
  3. *
  4. * Copyright 2023 Michael Farrell <micolous+git@gmail.com>
  5. *
  6. * This will only read "standard" MIFARE DESFire-based Opal cards. Free travel
  7. * cards (including School Opal cards, veteran, vision-impaired persons and
  8. * TfNSW employees' cards) and single-trip tickets are MIFARE Ultralight C
  9. * cards and not supported.
  10. *
  11. * Reference: https://github.com/metrodroid/metrodroid/wiki/Opal
  12. *
  13. * Note: The card values are all little-endian (like Flipper), but the above
  14. * reference was originally written based on Java APIs, which are big-endian.
  15. * This implementation presumes a little-endian system.
  16. *
  17. * This program is free software: you can redistribute it and/or modify it
  18. * under the terms of the GNU General Public License as published by
  19. * the Free Software Foundation, either version 3 of the License, or
  20. * (at your option) any later version.
  21. *
  22. * This program is distributed in the hope that it will be useful, but
  23. * WITHOUT ANY WARRANTY; without even the implied warranty of
  24. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
  25. * General Public License for more details.
  26. *
  27. * You should have received a copy of the GNU General Public License
  28. * along with this program. If not, see <http://www.gnu.org/licenses/>.
  29. */
  30. #include "../metroflip_i.h"
  31. #include <flipper_application.h>
  32. #include <lib/nfc/protocols/mf_desfire/mf_desfire.h>
  33. #include <lib/nfc/protocols/mf_desfire/mf_desfire_poller.h>
  34. #include <applications/services/locale/locale.h>
  35. #include <datetime.h>
  36. #define TAG "Metroflip:Scene:Opal"
  37. static const MfDesfireApplicationId opal_app_id = {.data = {0x31, 0x45, 0x53}};
  38. static const MfDesfireFileId opal_file_id = 0x07;
  39. static const char* opal_modes[5] =
  40. {"Rail / Metro", "Ferry / Light Rail", "Bus", "Unknown mode", "Manly Ferry"};
  41. static const char* opal_usages[14] = {
  42. "New / Unused",
  43. "Tap on: new journey",
  44. "Tap on: transfer from same mode",
  45. "Tap on: transfer from other mode",
  46. NULL, // Manly Ferry: new journey
  47. NULL, // Manly Ferry: transfer from ferry
  48. NULL, // Manly Ferry: transfer from other
  49. "Tap off: distance fare",
  50. "Tap off: flat fare",
  51. "Automated tap off: failed to tap off",
  52. "Tap off: end of trip without start",
  53. "Tap off: reversal",
  54. "Tap on: rejected",
  55. "Unknown usage",
  56. };
  57. // Opal file 0x7 structure. Assumes a little-endian CPU.
  58. typedef struct FURI_PACKED {
  59. uint32_t serial : 32;
  60. uint8_t check_digit : 4;
  61. bool blocked : 1;
  62. uint16_t txn_number : 16;
  63. int32_t balance : 21;
  64. uint16_t days : 15;
  65. uint16_t minutes : 11;
  66. uint8_t mode : 3;
  67. uint16_t usage : 4;
  68. bool auto_topup : 1;
  69. uint8_t weekly_journeys : 4;
  70. uint16_t checksum : 16;
  71. } OpalFile;
  72. static_assert(sizeof(OpalFile) == 16, "OpalFile");
  73. // Converts an Opal timestamp to DateTime.
  74. //
  75. // Opal measures days since 1980-01-01 and minutes since midnight, and presumes
  76. // all days are 1440 minutes.
  77. static void opal_days_minutes_to_datetime(uint16_t days, uint16_t minutes, DateTime* out) {
  78. out->year = 1980;
  79. out->month = 1;
  80. // 1980-01-01 is a Tuesday
  81. out->weekday = ((days + 1) % 7) + 1;
  82. out->hour = minutes / 60;
  83. out->minute = minutes % 60;
  84. out->second = 0;
  85. // What year is it?
  86. for(;;) {
  87. const uint16_t num_days_in_year = datetime_get_days_per_year(out->year);
  88. if(days < num_days_in_year) break;
  89. days -= num_days_in_year;
  90. out->year++;
  91. }
  92. // 1-index the day of the year
  93. days++;
  94. for(;;) {
  95. // What month is it?
  96. const bool is_leap = datetime_is_leap_year(out->year);
  97. const uint8_t num_days_in_month = datetime_get_days_per_month(is_leap, out->month);
  98. if(days <= num_days_in_month) break;
  99. days -= num_days_in_month;
  100. out->month++;
  101. }
  102. out->day = days;
  103. }
  104. bool opal_parse(const NfcDevice* device, FuriString* parsed_data) {
  105. furi_assert(device);
  106. furi_assert(parsed_data);
  107. const MfDesfireData* data = nfc_device_get_data(device, NfcProtocolMfDesfire);
  108. bool parsed = false;
  109. do {
  110. const MfDesfireApplication* app = mf_desfire_get_application(data, &opal_app_id);
  111. if(app == NULL) break;
  112. const MfDesfireFileSettings* file_settings =
  113. mf_desfire_get_file_settings(app, &opal_file_id);
  114. if(file_settings == NULL || file_settings->type != MfDesfireFileTypeStandard ||
  115. file_settings->data.size != sizeof(OpalFile))
  116. break;
  117. const MfDesfireFileData* file_data = mf_desfire_get_file_data(app, &opal_file_id);
  118. if(file_data == NULL) break;
  119. const OpalFile* opal_file = simple_array_cget_data(file_data->data);
  120. const uint8_t serial2 = opal_file->serial / 10000000;
  121. const uint16_t serial3 = (opal_file->serial / 1000) % 10000;
  122. const uint16_t serial4 = (opal_file->serial % 1000);
  123. if(opal_file->check_digit > 9) break;
  124. // Negative balance. Make this a positive value again and record the
  125. // sign separately, because then we can handle balances of -99..-1
  126. // cents, as the "dollars" division below would result in a positive
  127. // zero value.
  128. const bool is_negative_balance = (opal_file->balance < 0);
  129. const char* sign = is_negative_balance ? "-" : "";
  130. const int32_t balance = is_negative_balance ? labs(opal_file->balance) : //-V1081
  131. opal_file->balance;
  132. const uint8_t balance_cents = balance % 100;
  133. const int32_t balance_dollars = balance / 100;
  134. DateTime timestamp;
  135. opal_days_minutes_to_datetime(opal_file->days, opal_file->minutes, &timestamp);
  136. // Usages 4..6 associated with the Manly Ferry, which correspond to
  137. // usages 1..3 for other modes.
  138. const bool is_manly_ferry = (opal_file->usage >= 4) && (opal_file->usage <= 6);
  139. // 3..7 are "reserved", but we use 4 to indicate the Manly Ferry.
  140. const uint8_t mode = is_manly_ferry ? 4 : opal_file->mode;
  141. const uint8_t usage = is_manly_ferry ? opal_file->usage - 3 : opal_file->usage;
  142. const char* mode_str = opal_modes[mode > 4 ? 3 : mode];
  143. const char* usage_str = opal_usages[usage > 12 ? 13 : usage];
  144. furi_string_printf(
  145. parsed_data,
  146. "\e#Opal: $%s%ld.%02hu\nNo.: 3085 22%02hhu %04hu %03hu%01hhu\n%s, %s\n",
  147. sign,
  148. balance_dollars,
  149. balance_cents,
  150. serial2,
  151. serial3,
  152. serial4,
  153. opal_file->check_digit,
  154. mode_str,
  155. usage_str);
  156. FuriString* timestamp_str = furi_string_alloc();
  157. locale_format_date(timestamp_str, &timestamp, locale_get_date_format(), "-");
  158. furi_string_cat(parsed_data, timestamp_str);
  159. furi_string_cat(parsed_data, " at ");
  160. locale_format_time(timestamp_str, &timestamp, locale_get_time_format(), false);
  161. furi_string_cat(parsed_data, timestamp_str);
  162. furi_string_free(timestamp_str);
  163. furi_string_cat_printf(
  164. parsed_data,
  165. "\nWeekly journeys: %hhu, Txn #%hu\n",
  166. opal_file->weekly_journeys,
  167. opal_file->txn_number);
  168. if(opal_file->auto_topup) {
  169. furi_string_cat_str(parsed_data, "Auto-topup enabled\n");
  170. }
  171. if(opal_file->blocked) {
  172. furi_string_cat_str(parsed_data, "Card blocked\n");
  173. }
  174. parsed = true;
  175. } while(false);
  176. return parsed;
  177. }
  178. static NfcCommand metroflip_scene_opal_poller_callback(NfcGenericEvent event, void* context) {
  179. furi_assert(event.protocol == NfcProtocolMfDesfire);
  180. Metroflip* app = context;
  181. NfcCommand command = NfcCommandContinue;
  182. FuriString* parsed_data = furi_string_alloc();
  183. Widget* widget = app->widget;
  184. furi_string_reset(app->text_box_store);
  185. const MfDesfirePollerEvent* mf_desfire_event = event.event_data;
  186. if(mf_desfire_event->type == MfDesfirePollerEventTypeReadSuccess) {
  187. nfc_device_set_data(
  188. app->nfc_device, NfcProtocolMfDesfire, nfc_poller_get_data(app->poller));
  189. if(!opal_parse(app->nfc_device, parsed_data)) {
  190. furi_string_reset(app->text_box_store);
  191. FURI_LOG_I(TAG, "Unknown card type");
  192. furi_string_printf(parsed_data, "\e#Unknown card\n");
  193. }
  194. widget_add_text_scroll_element(widget, 0, 0, 128, 64, furi_string_get_cstr(parsed_data));
  195. widget_add_button_element(
  196. widget, GuiButtonTypeRight, "Exit", metroflip_exit_widget_callback, app);
  197. furi_string_free(parsed_data);
  198. view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewWidget);
  199. metroflip_app_blink_stop(app);
  200. command = NfcCommandStop;
  201. } else if(mf_desfire_event->type == MfDesfirePollerEventTypeReadFailed) {
  202. view_dispatcher_send_custom_event(app->view_dispatcher, MetroflipCustomEventPollerSuccess);
  203. command = NfcCommandContinue;
  204. }
  205. return command;
  206. }
  207. void metroflip_scene_opal_on_enter(void* context) {
  208. Metroflip* app = context;
  209. dolphin_deed(DolphinDeedNfcRead);
  210. // Setup view
  211. Popup* popup = app->popup;
  212. popup_set_header(popup, "Apply\n card to\nthe back", 68, 30, AlignLeft, AlignTop);
  213. popup_set_icon(popup, 0, 3, &I_RFIDDolphinReceive_97x61);
  214. // Start worker
  215. view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewPopup);
  216. nfc_scanner_alloc(app->nfc);
  217. app->poller = nfc_poller_alloc(app->nfc, NfcProtocolMfDesfire);
  218. nfc_poller_start(app->poller, metroflip_scene_opal_poller_callback, app);
  219. metroflip_app_blink_start(app);
  220. }
  221. bool metroflip_scene_opal_on_event(void* context, SceneManagerEvent event) {
  222. Metroflip* app = context;
  223. bool consumed = false;
  224. if(event.type == SceneManagerEventTypeCustom) {
  225. if(event.event == MetroflipCustomEventCardDetected) {
  226. Popup* popup = app->popup;
  227. popup_set_header(popup, "DON'T\nMOVE", 68, 30, AlignLeft, AlignTop);
  228. consumed = true;
  229. } else if(event.event == MetroflipCustomEventCardLost) {
  230. Popup* popup = app->popup;
  231. popup_set_header(popup, "Card \n lost", 68, 30, AlignLeft, AlignTop);
  232. consumed = true;
  233. } else if(event.event == MetroflipCustomEventWrongCard) {
  234. Popup* popup = app->popup;
  235. popup_set_header(popup, "WRONG \n CARD", 68, 30, AlignLeft, AlignTop);
  236. consumed = true;
  237. } else if(event.event == MetroflipCustomEventPollerFail) {
  238. Popup* popup = app->popup;
  239. popup_set_header(popup, "Failed", 68, 30, AlignLeft, AlignTop);
  240. consumed = true;
  241. }
  242. } else if(event.type == SceneManagerEventTypeBack) {
  243. scene_manager_search_and_switch_to_previous_scene(app->scene_manager, MetroflipSceneStart);
  244. consumed = true;
  245. }
  246. return consumed;
  247. }
  248. void metroflip_scene_opal_on_exit(void* context) {
  249. Metroflip* app = context;
  250. widget_reset(app->widget);
  251. metroflip_app_blink_stop(app);
  252. if(app->poller) {
  253. nfc_poller_stop(app->poller);
  254. nfc_poller_free(app->poller);
  255. }
  256. }