Browse Source

Merge metroflip from https://github.com/luu176/Metroflip

Willy-JL 1 year ago
parent
commit
6812720b8e
35 changed files with 5698 additions and 555 deletions
  1. 50 0
      metroflip/.github/workflows/build&push-beta.yml
  2. 46 0
      metroflip/.github/workflows/build&push.yml
  3. 1 9
      metroflip/.github/workflows/main.yml
  4. 20 1
      metroflip/CHANGELOG.md
  5. 85 26
      metroflip/README.md
  6. 251 0
      metroflip/api/calypso/calypso_util.c
  7. 80 0
      metroflip/api/calypso/calypso_util.h
  8. 379 0
      metroflip/api/calypso/cards/navigo.c
  9. 10 0
      metroflip/api/calypso/cards/navigo.h
  10. 1322 0
      metroflip/api/mosgortrans/mosgortrans_util.c
  11. 22 0
      metroflip/api/mosgortrans/mosgortrans_util.h
  12. 19 14
      metroflip/app/README.md
  13. 1 1
      metroflip/application.fam
  14. 4 2
      metroflip/manifest.yml
  15. 9 0
      metroflip/metroflip.c
  16. 6 0
      metroflip/metroflip_i.h
  17. 1 10
      metroflip/scenes/metroflip_scene_about.c
  18. 1 10
      metroflip/scenes/metroflip_scene_bip.c
  19. 3 14
      metroflip/scenes/metroflip_scene_charliecard.c
  20. 659 0
      metroflip/scenes/metroflip_scene_clipper.c
  21. 5 0
      metroflip/scenes/metroflip_scene_config.h
  22. 8 11
      metroflip/scenes/metroflip_scene_credits.c
  23. 205 0
      metroflip/scenes/metroflip_scene_itso.c
  24. 188 0
      metroflip/scenes/metroflip_scene_myki.c
  25. 907 158
      metroflip/scenes/metroflip_scene_navigo.c
  26. 309 0
      metroflip/scenes/metroflip_scene_opal.c
  27. 1 10
      metroflip/scenes/metroflip_scene_ravkav.c
  28. 1 10
      metroflip/scenes/metroflip_scene_read_success.c
  29. 15 0
      metroflip/scenes/metroflip_scene_start.c
  30. 311 0
      metroflip/scenes/metroflip_scene_troika.c
  31. 706 279
      metroflip/scenes/navigo.h
  32. 73 0
      metroflip/scenes/navigo_structs.h
  33. BIN
      metroflip/screenshots/Menu-Top.png
  34. BIN
      metroflip/screenshots/Navigo.png
  35. BIN
      metroflip/screenshots/Navigo2.png

+ 50 - 0
metroflip/.github/workflows/build&push-beta.yml

@@ -0,0 +1,50 @@
+name: Build and Upload FAP to Beta
+
+on:
+  workflow_dispatch:
+    inputs:
+      version:
+        description: 'Version number to use for the release'
+        required: true
+        default: '1.0.0'
+
+    branches:
+      - dev
+
+permissions:
+  contents: write 
+
+jobs:
+  build-and-upload:
+    name: Build and Upload FAP
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout Repository
+        uses: actions/checkout@v3
+        with:
+          ref: dev  
+
+      - name: Set up Python
+        uses: actions/setup-python@v4
+        with:
+          python-version: '3.x'
+
+      - name: Install UFBT
+        run: |
+          python3 -m pip install --upgrade pip
+          pip install ufbt
+
+      - name: Initialize UFBT Environment
+        run: |
+          ufbt update
+          ufbt vscode_dist
+
+      - name: Build FAP Applications
+        run: ufbt faps
+
+      - name: Upload Build Outputs to Release
+        run: |
+          gh release upload v${{ github.event.inputs.version }} /home/runner/.ufbt/build/metroflip.fap
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

+ 46 - 0
metroflip/.github/workflows/build&push.yml

@@ -0,0 +1,46 @@
+name: Build and Upload FAP to Release
+
+on:
+  workflow_dispatch: 
+
+permissions:
+  contents: write 
+
+jobs:
+  build-and-upload:
+    name: Build and Upload FAP
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout Repository
+        uses: actions/checkout@v3
+
+      - name: Extract Version from Manifest
+        id: extract_version
+        run: |
+          VERSION=$(grep '^version:' manifest.yml | awk '{print $2}')
+          echo "VERSION=${VERSION}" >> $GITHUB_ENV
+
+      - name: Set up Python
+        uses: actions/setup-python@v4
+        with:
+          python-version: '3.x'
+
+      - name: Install UFBT
+        run: |
+          python3 -m pip install --upgrade pip
+          pip install ufbt
+
+      - name: Initialize UFBT Environment
+        run: |
+          ufbt update
+          ufbt vscode_dist
+
+      - name: Build FAP Applications
+        run: ufbt faps
+
+      - name: Upload Build Outputs to Release
+        run: |
+          gh release upload v${{ env.VERSION }} /home/runner/.ufbt/build/metroflip.fap
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

+ 1 - 9
metroflip/.github/workflows/main.yml

@@ -1,5 +1,4 @@
 name: UFBT Build and Test
 name: UFBT Build and Test
-
 on:
 on:
   push:
   push:
     branches:
     branches:
@@ -7,36 +6,29 @@ on:
   pull_request:
   pull_request:
     branches:
     branches:
       - main
       - main
-
 jobs:
 jobs:
   build:
   build:
     name: Build and Test Application
     name: Build and Test Application
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
-
     steps:
     steps:
       - name: Checkout Repository
       - name: Checkout Repository
         uses: actions/checkout@v3
         uses: actions/checkout@v3
-
       - name: Set up Python
       - name: Set up Python
         uses: actions/setup-python@v4
         uses: actions/setup-python@v4
         with:
         with:
           python-version: '3.x'
           python-version: '3.x'
-
       - name: Install UFBT
       - name: Install UFBT
         run: |
         run: |
           python3 -m pip install --upgrade pip
           python3 -m pip install --upgrade pip
           pip install ufbt
           pip install ufbt
-
       - name: Initialize UFBT Environment
       - name: Initialize UFBT Environment
         run: |
         run: |
           ufbt update
           ufbt update
           ufbt vscode_dist
           ufbt vscode_dist
-
       - name: Build FAP Applications
       - name: Build FAP Applications
         run: ufbt faps
         run: ufbt faps
-
       - name: Upload Build Artifacts
       - name: Upload Build Artifacts
         uses: actions/upload-artifact@v3
         uses: actions/upload-artifact@v3
         with:
         with:
           name: build-output
           name: build-output
-          path: build/
+          path: build/

+ 20 - 1
metroflip/CHANGELOG.md

@@ -1,8 +1,27 @@
 ## v0.1
 ## v0.1
 
 
 - Initial release by luu176
 - Initial release by luu176
+
 ## v0.2
 ## v0.2
 
 
 - Update Rav-Kav parsing to show more data such as transaction logs
 - Update Rav-Kav parsing to show more data such as transaction logs
-- Add Navigo parser!
+- Add Navigo parser! (Paris, France)
 - Bug fixes
 - Bug fixes
+
+## v0.3
+
+- Added Clipper parser (San Francisco, CA, USA)
+- Added Troika parser (Moscow, Russia)
+- Added Myki parser (Melbourne (and surrounds), VIC, Australia)
+- Added Opal parser (Sydney (and surrounds), NSW, Australia)
+- Added ITSO parser (United Kingdom)
+
+## v0.4
+
+- Updated Navigo parser (Paris, France) (thanks to: DocSystem)
+  - Now use a global Calypso parser, with defined structures
+  - Fix Busfault and NULL Pointer Dereferences
+- Updated all Desfire parsers (opal, itso, myki, etc..)
+  - Now doesnt crash when you click the back button while reading
+- Fix Charliecard parser
+

+ 85 - 26
metroflip/README.md

@@ -1,45 +1,104 @@
 # Metroflip
 # Metroflip
-Metroflip is a multi-protocol metro card reader app for the Flipper Zero, inspired by the Metrodroid project. It enables the parsing and analysis of metro cards from transit systems around the world, providing a proof-of-concept for exploring transit card data in a portable format.
+Metroflip is a multi-protocol metro card reader app for the Flipper Zero, inspired by the Metrodroid project. It enables the parsing and analysis of metro cards from transit systems around the world, providing a proof-of-concept for exploring transit card data in a portable format. 
 
 
 # Author
 # Author
 [@luu176](https://github.com/luu176)
 [@luu176](https://github.com/luu176)
 
 
+# Discord Community Server 
+
+Please join the server https://discord.gg/NR5hhbAXqS if you have any questions for me.
+---
+
+![image](screenshots/Menu-Top.png)
+
+# Setup Instructions
+
+## Using a pre-built release: Stable (Recommended) or Beta (Newer updates, less stable)
+1. Download the appropriate `metroflip.fap` file from the [Releases section](https://github.com/luu176/Metroflip/releases).
+2. Drag and drop the `metroflip.fap` file into the `apps` folder on your Flipper Zero's SD card.
+
+## Manual Build Instructions
+To build Metroflip manually, follow these steps:
+
+1. **Install Git**  
+   Download and install Git on your Windows computer.  
+   Run the first command to download the app:  
+
+**Either**:
+Stable Release (recommended): 
+```git clone https://github.com/luu176/Metroflip.git```
+
+**OR**:
+Beta (newer updates but not fully tested): 
+```git clone --single-branch --branch dev https://github.com/luu176/Metroflip.git```
+
+2. **Navigate to the Project Folder**  
+Run the second command to enter the app folder:  
+
+```cd Metroflip```
+
+3. **Install Python**  
+Download and install Python from the [official website](https://www.python.org).  
+
+4. **Install UFBT**  
+Run the third command to install UFBT:  
+
+```pip install ufbt```
+
+5. **Update and Build the Project**  
+Run the following commands in order to build the app:  
+
+```ufbt update```
+```ufbt fap_metroflip```
+
+6. **Connect Your Flipper Zero**  
+Ensure your Flipper Zero is connected via USB and close the QFlipper application (if it’s open).  
+
+7. **Launch the Build**  
+Run the final command to launch the app on your flipper:  
+
+```ufbt launch```
+
+---
+
 # Metroflip - Card Support TODO List
 # Metroflip - Card Support TODO List
 
 
 This is a list of metro cards and transit systems that need support or have partial support.
 This is a list of metro cards and transit systems that need support or have partial support.
 
 
 ## ✅ Supported Cards
 ## ✅ Supported Cards
-- [x] **Rav-Kav**  
-  - Status: Needs more functionality (currently only able to read balance).
-- [x] **Charliecard**  
-  - Status: Fully supported.
-- [x] **Metromoney**  
-  - Status: Fully supported.
-- [x] **Bip!**  
-  - Status: Fully supported.
-- [x] **Navigo**  
-  - Status: Fully supported. (v0.2)
-
-## 🚧 In Progress / Needs More Functionality
-- [ ] **Rav-Kav**  
-  - Current functionality: Reads balance only.  (v0.1)
-  - To Do: Parse more data from the card (e.g., transaction history, expiration date, etc.). (v0.2)
-
-## 📝 To Do (Unimplemented)
-- [ ] **Tianjin Railway Transit (TRT)**  
-  - To Do: Add support for reading and analyzing Tianjin Railway Transit cards.
-- [ ] **Clipper**  
-  - To Do: Add support for reading and analyzing Clipper cards. (v0.3)
+
+| **Card / Agency** | **Country / City**                  | **Card Type**     |
+|--------------------|-------------------------------------|-------------------|
+| **Bip!**          | 🇨🇱 Santiago de Chile, Chile        | Mifare Classic    |
+| **Charliecard**    | 🇺🇸 Boston, MA, USA                 | Mifare Classic    |
+| **Clipper**        | 🇺🇸 San Francisco, CA, USA          | Mifare DESFire    |
+| **ITSO**           | 🇬🇧 United Kingdom                 | Mifare DESFire    |
+| **Metromoney**     | 🇬🇪 Tbilisi, Georgia                | Mifare Classic    |
+| **myki**           | 🇦🇺 Melbourne (and surrounds), VIC, Australia | Mifare DESFire |
+| **Navigo**         | 🇫🇷 Paris, France                   | Calypso           |
+| **Opal**           | 🇦🇺 Sydney (and surrounds), NSW, Australia | Mifare DESFire |
+| **Rav-Kav**        | 🇮🇱 Israel                          | Calypso           |
+| **Troika**         | 🇷🇺 Moscow, Russia                  | Mifare Classic    |
+
 
 
 
 
 ---
 ---
 
 
-### Credits:
+# Credits
 - **App Author**: [@luu176](https://github.com/luu176)
 - **App Author**: [@luu176](https://github.com/luu176)
 - **Charliecard Parser**: [@zacharyweiss](https://github.com/zacharyweiss)
 - **Charliecard Parser**: [@zacharyweiss](https://github.com/zacharyweiss)
 - **Rav-Kav Parser**: [@luu176](https://github.com/luu176)
 - **Rav-Kav Parser**: [@luu176](https://github.com/luu176)
-- **Navigo Parser**: [@luu176](https://github.com/luu176)
+- **Navigo Parser**: [@luu176](https://github.com/luu176), [@DocSystem](https://github.com/DocSystem)
 - **Metromoney Parser**: [@Leptopt1los](https://github.com/Leptopt1los)
 - **Metromoney Parser**: [@Leptopt1los](https://github.com/Leptopt1los)
-- **Bip! Parser**: [@rbasoalto](https://github.com/rbasoalto) [@gornekich](https://github.com/gornekich)
-- **Info Slave**: [@equipter](https://github.com/equipter)
+- **Bip! Parser**: [@rbasoalto](https://github.com/rbasoalto), [@gornekich](https://github.com/gornekich)
+- **Clipper Parser**: [@ke6jjj](https://github.com/ke6jjj)
+- **Troika Parser**: [@gornekich](https://github.com/gornekich)
+- **Myki Parser**: [@gornekich](https://github.com/gornekich)
+- **Opal Parser**: [@gornekich](https://github.com/gornekich)
+- **ITSO Parser**: [@gsp8181](https://github.com/gsp8181), [@hedger](https://github.com/hedger), [@gornekich](https://github.com/gornekich)
+- **Info Slaves**: [@equipter](https://github.com/equipter), [TheDingo8MyBaby](https://github.com/TheDingo8MyBaby)
+
+---
 
 
+### Special Thanks
+Huge thanks to [@equipter](https://github.com/equipter) for helping out the community!

+ 251 - 0
metroflip/api/calypso/calypso_util.c

@@ -0,0 +1,251 @@
+#include <stdlib.h>
+#include <string.h>
+#include "calypso_util.h"
+
+CalypsoElement make_calypso_final_element(
+    const char* key,
+    int size,
+    const char* label,
+    CalypsoFinalType final_type) {
+    CalypsoElement final_element = {};
+
+    final_element.type = CALYPSO_ELEMENT_TYPE_FINAL;
+    final_element.final = malloc(sizeof(CalypsoFinalElement));
+    final_element.final->size = size;
+    final_element.final->final_type = final_type;
+    strncpy(final_element.final->key, key, 36);
+    strncpy(final_element.final->label, label, 64);
+
+    return final_element;
+}
+
+CalypsoElement make_calypso_bitmap_element(const char* key, int size, CalypsoElement* elements) {
+    CalypsoElement bitmap_element = {};
+
+    bitmap_element.type = CALYPSO_ELEMENT_TYPE_BITMAP;
+    bitmap_element.bitmap = malloc(sizeof(CalypsoBitmapElement));
+    bitmap_element.bitmap->size = size;
+    bitmap_element.bitmap->elements = malloc(size * sizeof(CalypsoElement));
+    for(int i = 0; i < size; i++) {
+        bitmap_element.bitmap->elements[i] = elements[i];
+    }
+    strncpy(bitmap_element.bitmap->key, key, 36);
+
+    return bitmap_element;
+}
+
+void free_calypso_element(CalypsoElement* element) {
+    if(element->type == CALYPSO_ELEMENT_TYPE_FINAL) {
+        free(element->final);
+    } else {
+        for(int i = 0; i < element->bitmap->size; i++) {
+            free_calypso_element(&element->bitmap->elements[i]);
+        }
+        free(element->bitmap->elements);
+        free(element->bitmap);
+    }
+}
+
+void free_calypso_structure(CalypsoApp* structure) {
+    for(int i = 0; i < structure->elements_size; i++) {
+        free_calypso_element(&structure->elements[i]);
+    }
+    free(structure->elements);
+    free(structure);
+}
+
+int* get_bit_positions(const char* binary_string, int* count) {
+    int length = strlen(binary_string);
+    int* positions = malloc(length * sizeof(int));
+    int pos_index = 0;
+
+    for(int i = 0; i < length; i++) {
+        if(binary_string[length - 1 - i] == '1') {
+            positions[pos_index++] = i;
+        }
+    }
+
+    *count = pos_index;
+    return positions;
+}
+
+int is_bit_present(int* positions, int count, int bit) {
+    for(int i = 0; i < count; i++) {
+        if(positions[i] == bit) {
+            return 1;
+        }
+    }
+    return 0;
+}
+
+bool is_calypso_subnode_present(
+    const char* binary_string,
+    const char* key,
+    CalypsoBitmapElement* bitmap) {
+    char bit_slice[bitmap->size + 1];
+    strncpy(bit_slice, binary_string, bitmap->size);
+    bit_slice[bitmap->size] = '\0';
+    int count = 0;
+    int* positions = get_bit_positions(bit_slice, &count);
+    int offset = bitmap->size;
+    for(int i = 0; i < count; i++) {
+        CalypsoElement* element = &bitmap->elements[positions[i]];
+        if(element->type == CALYPSO_ELEMENT_TYPE_FINAL) {
+            if(strcmp(element->final->key, key) == 0) {
+                free(positions);
+                return true;
+            }
+            offset += element->final->size;
+        } else {
+            if(strcmp(element->bitmap->key, key) == 0) {
+                free(positions);
+                return true;
+            }
+            int sub_binary_string_size = element->bitmap->size;
+            char bit_slice[sub_binary_string_size + 1];
+            strncpy(bit_slice, binary_string, sub_binary_string_size);
+            bit_slice[sub_binary_string_size] = '\0';
+            if(is_calypso_subnode_present(binary_string + offset, key, element->bitmap)) {
+                free(positions);
+                return true;
+            }
+            offset += element->bitmap->size;
+        }
+    }
+    free(positions);
+    return false;
+}
+
+bool is_calypso_node_present(const char* binary_string, const char* key, CalypsoApp* structure) {
+    int offset = 0;
+    for(int i = 0; i < structure->elements_size; i++) {
+        if(structure->elements[i].type == CALYPSO_ELEMENT_TYPE_FINAL) {
+            if(strcmp(structure->elements[i].final->key, key) == 0) {
+                return true;
+            }
+            offset += structure->elements[i].final->size;
+        } else {
+            if(strcmp(structure->elements[i].bitmap->key, key) == 0) {
+                return true;
+            }
+            int sub_binary_string_size = structure->elements[i].bitmap->size;
+            char bit_slice[sub_binary_string_size + 1];
+            strncpy(bit_slice, binary_string, sub_binary_string_size);
+            bit_slice[sub_binary_string_size] = '\0';
+            if(is_calypso_subnode_present(
+                   binary_string + offset, key, structure->elements[i].bitmap)) {
+                return true;
+            }
+            offset += structure->elements[i].bitmap->size;
+        }
+    }
+    return false;
+}
+
+int get_calypso_subnode_offset(
+    const char* binary_string,
+    const char* key,
+    CalypsoBitmapElement* bitmap,
+    bool* found) {
+    char bit_slice[bitmap->size + 1];
+    strncpy(bit_slice, binary_string, bitmap->size);
+    bit_slice[bitmap->size] = '\0';
+
+    int count = 0;
+    int* positions = get_bit_positions(bit_slice, &count);
+
+    int count_offset = bitmap->size;
+    for(int i = 0; i < count; i++) {
+        CalypsoElement element = bitmap->elements[positions[i]];
+        if(element.type == CALYPSO_ELEMENT_TYPE_FINAL) {
+            if(strcmp(element.final->key, key) == 0) {
+                *found = true;
+                free(positions);
+                return count_offset;
+            }
+            count_offset += element.final->size;
+        } else {
+            if(strcmp(element.bitmap->key, key) == 0) {
+                *found = true;
+                free(positions);
+                return count_offset;
+            }
+            count_offset += get_calypso_subnode_offset(
+                binary_string + count_offset, key, element.bitmap, found);
+            if(*found) {
+                free(positions);
+                return count_offset;
+            }
+        }
+    }
+    free(positions);
+    return count_offset;
+}
+
+int get_calypso_node_offset(const char* binary_string, const char* key, CalypsoApp* structure) {
+    int count = 0;
+    bool found = false;
+    for(int i = 0; i < structure->elements_size; i++) {
+        if(structure->elements[i].type == CALYPSO_ELEMENT_TYPE_FINAL) {
+            if(strcmp(structure->elements[i].final->key, key) == 0) {
+                return count;
+            }
+            count += structure->elements[i].final->size;
+        } else {
+            if(strcmp(structure->elements[i].bitmap->key, key) == 0) {
+                return count;
+            }
+            int sub_binary_string_size = structure->elements[i].bitmap->size;
+            char bit_slice[sub_binary_string_size + 1];
+            strncpy(bit_slice, binary_string + count, sub_binary_string_size);
+            bit_slice[sub_binary_string_size] = '\0';
+            count += get_calypso_subnode_offset(
+                binary_string + count, key, structure->elements[i].bitmap, &found);
+            if(found) {
+                return count;
+            }
+        }
+    }
+    return 0;
+}
+
+int get_calypso_subnode_size(const char* key, CalypsoElement* element) {
+    if(element->type == CALYPSO_ELEMENT_TYPE_FINAL) {
+        if(strcmp(element->final->key, key) == 0) {
+            return element->final->size;
+        }
+    } else {
+        if(strcmp(element->bitmap->key, key) == 0) {
+            return element->bitmap->size;
+        }
+        for(int i = 0; i < element->bitmap->size; i++) {
+            int size = get_calypso_subnode_size(key, &element->bitmap->elements[i]);
+            if(size != 0) {
+                return size;
+            }
+        }
+    }
+    return 0;
+}
+
+int get_calypso_node_size(const char* key, CalypsoApp* structure) {
+    for(int i = 0; i < structure->elements_size; i++) {
+        if(structure->elements[i].type == CALYPSO_ELEMENT_TYPE_FINAL) {
+            if(strcmp(structure->elements[i].final->key, key) == 0) {
+                return structure->elements[i].final->size;
+            }
+        } else {
+            if(strcmp(structure->elements[i].bitmap->key, key) == 0) {
+                return structure->elements[i].bitmap->size;
+            }
+            for(int j = 0; j < structure->elements[i].bitmap->size; j++) {
+                int size =
+                    get_calypso_subnode_size(key, &structure->elements[i].bitmap->elements[j]);
+                if(size != 0) {
+                    return size;
+                }
+            }
+        }
+    }
+    return 0;
+}

+ 80 - 0
metroflip/api/calypso/calypso_util.h

@@ -0,0 +1,80 @@
+#include <stdbool.h>
+
+#ifndef CALYPSO_UTIL_H
+#define CALYPSO_UTIL_H
+
+typedef enum {
+    CALYPSO_APP_CONTRACT,
+} CalypsoAppType;
+
+typedef enum {
+    CALYPSO_FINAL_TYPE_UNKNOWN,
+    CALYPSO_FINAL_TYPE_NUMBER,
+    CALYPSO_FINAL_TYPE_DATE,
+    CALYPSO_FINAL_TYPE_TIME,
+    CALYPSO_FINAL_TYPE_PAY_METHOD,
+    CALYPSO_FINAL_TYPE_AMOUNT,
+    CALYPSO_FINAL_TYPE_SERVICE_PROVIDER,
+    CALYPSO_FINAL_TYPE_ZONES,
+    CALYPSO_FINAL_TYPE_TARIFF,
+    CALYPSO_FINAL_TYPE_NETWORK_ID,
+    CALYPSO_FINAL_TYPE_TRANSPORT_TYPE,
+    CALYPSO_FINAL_TYPE_CARD_STATUS,
+} CalypsoFinalType;
+
+typedef enum {
+    CALYPSO_ELEMENT_TYPE_BITMAP,
+    CALYPSO_ELEMENT_TYPE_FINAL
+} CalypsoElementType;
+
+typedef struct CalypsoFinalElement_t CalypsoFinalElement;
+typedef struct CalypsoBitmapElement_t CalypsoBitmapElement;
+
+typedef struct {
+    CalypsoElementType type;
+    union {
+        CalypsoFinalElement* final;
+        CalypsoBitmapElement* bitmap;
+    };
+} CalypsoElement;
+
+struct CalypsoFinalElement_t {
+    char key[36];
+    int size;
+    char label[64];
+    CalypsoFinalType final_type;
+};
+
+struct CalypsoBitmapElement_t {
+    char key[36];
+    int size;
+    CalypsoElement* elements;
+};
+
+typedef struct {
+    CalypsoAppType type;
+    CalypsoElement* elements;
+    int elements_size;
+} CalypsoApp;
+
+CalypsoElement make_calypso_final_element(
+    const char* key,
+    int size,
+    const char* label,
+    CalypsoFinalType final_type);
+
+CalypsoElement make_calypso_bitmap_element(const char* key, int size, CalypsoElement* elements);
+
+void free_calypso_structure(CalypsoApp* structure);
+
+int* get_bit_positions(const char* binary_string, int* count);
+
+int is_bit_present(int* positions, int count, int bit);
+
+bool is_calypso_node_present(const char* binary_string, const char* key, CalypsoApp* structure);
+
+int get_calypso_node_offset(const char* binary_string, const char* key, CalypsoApp* structure);
+
+int get_calypso_node_size(const char* key, CalypsoApp* structure);
+
+#endif // CALYPSO_UTIL_H

+ 379 - 0
metroflip/api/calypso/cards/navigo.c

@@ -0,0 +1,379 @@
+#include <stdlib.h>
+#include "navigo.h"
+
+CalypsoApp* get_navigo_contract_structure() {
+    CalypsoApp* NavigoContractStructure = malloc(sizeof(CalypsoApp));
+    if(!NavigoContractStructure) {
+        return NULL;
+    }
+
+    int app_elements_count = 1;
+
+    NavigoContractStructure->type = CALYPSO_APP_CONTRACT;
+    NavigoContractStructure->elements = malloc(app_elements_count * sizeof(CalypsoElement));
+    NavigoContractStructure->elements_size = app_elements_count;
+
+    NavigoContractStructure->elements[0] = make_calypso_bitmap_element(
+        "Contract",
+        20,
+        (CalypsoElement[]){
+            make_calypso_final_element(
+                "ContractNetworkId", 24, "Identification du réseau", CALYPSO_FINAL_TYPE_UNKNOWN),
+
+            make_calypso_final_element(
+                "ContractProvider",
+                8,
+                "Identification de l’exploitant",
+                CALYPSO_FINAL_TYPE_UNKNOWN),
+            make_calypso_final_element(
+                "ContractTariff", 16, "Code tarif", CALYPSO_FINAL_TYPE_TARIFF),
+
+            make_calypso_final_element(
+                "ContractSerialNumber", 32, "Numéro TCN", CALYPSO_FINAL_TYPE_UNKNOWN),
+
+            make_calypso_bitmap_element(
+                "ContractCustomerInfoBitmap",
+                2,
+                (CalypsoElement[]){
+                    make_calypso_final_element(
+                        "ContractCustomerProfile",
+                        6,
+                        "Statut du titulaire ou Taux de réduction applicable",
+                        CALYPSO_FINAL_TYPE_UNKNOWN),
+                    make_calypso_final_element(
+                        "ContractCustomerNumber",
+                        32,
+                        "Numéro de client",
+                        CALYPSO_FINAL_TYPE_UNKNOWN),
+                }),
+
+            make_calypso_bitmap_element(
+                "ContractPassengerInfoBitmap",
+                2,
+                (CalypsoElement[]){
+                    make_calypso_final_element(
+                        "ContractPassengerClass",
+                        8,
+                        "Classe de service des voyageurs",
+                        CALYPSO_FINAL_TYPE_UNKNOWN),
+                    make_calypso_final_element(
+                        "ContractPassengerTotal",
+                        8,
+                        "Nombre total de voyageurs",
+                        CALYPSO_FINAL_TYPE_UNKNOWN),
+                }),
+
+            make_calypso_final_element(
+                "ContractVehicleClassAllowed",
+                6,
+                "Classes de véhicule autorisé",
+                CALYPSO_FINAL_TYPE_UNKNOWN),
+
+            make_calypso_final_element(
+                "ContractPaymentPointer",
+                32,
+                "Pointeurs sur les événements de paiement",
+                CALYPSO_FINAL_TYPE_UNKNOWN),
+
+            make_calypso_final_element(
+                "ContractPayMethod", 11, "Code mode de paiement", CALYPSO_FINAL_TYPE_PAY_METHOD),
+
+            make_calypso_final_element(
+                "ContractServices", 16, "Services autorisés", CALYPSO_FINAL_TYPE_UNKNOWN),
+
+            make_calypso_final_element(
+                "ContractPriceAmount", 16, "Montant total", CALYPSO_FINAL_TYPE_AMOUNT),
+
+            make_calypso_final_element(
+                "ContractPriceUnit", 16, "Code de monnaie", CALYPSO_FINAL_TYPE_UNKNOWN),
+
+            make_calypso_bitmap_element(
+                "ContractRestrictionBitmap",
+                7,
+                (CalypsoElement[]){
+                    make_calypso_final_element(
+                        "ContractRestrictStart", 11, "", CALYPSO_FINAL_TYPE_UNKNOWN),
+                    make_calypso_final_element(
+                        "ContractRestrictEnd", 11, "", CALYPSO_FINAL_TYPE_UNKNOWN),
+                    make_calypso_final_element(
+                        "ContractRestrictDay", 8, "", CALYPSO_FINAL_TYPE_UNKNOWN),
+                    make_calypso_final_element(
+                        "ContractRestrictTimeCode", 8, "", CALYPSO_FINAL_TYPE_UNKNOWN),
+                    make_calypso_final_element(
+                        "ContractRestrictCode",
+                        8,
+                        "Code de restriction",
+                        CALYPSO_FINAL_TYPE_UNKNOWN),
+                    make_calypso_final_element(
+                        "ContractRestrictProduct",
+                        16,
+                        "Produit de restriction",
+                        CALYPSO_FINAL_TYPE_UNKNOWN),
+                    make_calypso_final_element(
+                        "ContractRestrictLocation",
+                        16,
+                        "Référence du lieu de restriction",
+                        CALYPSO_FINAL_TYPE_UNKNOWN),
+                }),
+
+            make_calypso_bitmap_element(
+                "ContractValidityInfoBitmap",
+                9,
+                (CalypsoElement[]){
+                    make_calypso_final_element(
+                        "ContractValidityStartDate",
+                        14,
+                        "Date de début de validité",
+                        CALYPSO_FINAL_TYPE_DATE),
+                    make_calypso_final_element(
+                        "ContractValidityStartTime",
+                        11,
+                        "Heure de début de validité",
+                        CALYPSO_FINAL_TYPE_TIME),
+                    make_calypso_final_element(
+                        "ContractValidityEndDate",
+                        14,
+                        "Date de fin de validité",
+                        CALYPSO_FINAL_TYPE_DATE),
+                    make_calypso_final_element(
+                        "ContractValidityEndTime",
+                        11,
+                        "Heure de fin de validité",
+                        CALYPSO_FINAL_TYPE_TIME),
+                    make_calypso_final_element(
+                        "ContractValidityDuration",
+                        8,
+                        "Durée de validité",
+                        CALYPSO_FINAL_TYPE_UNKNOWN),
+                    make_calypso_final_element(
+                        "ContractValidityLimiteDate",
+                        14,
+                        "Date limite de première utilisation",
+                        CALYPSO_FINAL_TYPE_DATE),
+                    make_calypso_final_element(
+                        "ContractValidityZones",
+                        8,
+                        "Numéros des zones autorisées",
+                        CALYPSO_FINAL_TYPE_ZONES),
+                    make_calypso_final_element(
+                        "ContractValidityJourneys",
+                        16,
+                        "Nombre de voyages autorisés",
+                        CALYPSO_FINAL_TYPE_UNKNOWN),
+                    make_calypso_final_element(
+                        "ContractPeriodJourneys",
+                        16,
+                        "Nombre de voyages autorisés par période",
+                        CALYPSO_FINAL_TYPE_UNKNOWN),
+                }),
+
+            make_calypso_bitmap_element(
+                "ContractJourneyData",
+                8,
+                (CalypsoElement[]){
+                    make_calypso_final_element(
+                        "ContractJourneyOrigin",
+                        16,
+                        "Code lieu d’origine",
+                        CALYPSO_FINAL_TYPE_UNKNOWN),
+                    make_calypso_final_element(
+                        "ContractJourneyDestination",
+                        16,
+                        "Code lieu de destination",
+                        CALYPSO_FINAL_TYPE_UNKNOWN),
+                    make_calypso_final_element(
+                        "ContractJourneyRouteNumbers",
+                        16,
+                        "Numéros des lignes autorisées",
+                        CALYPSO_FINAL_TYPE_UNKNOWN),
+                    make_calypso_final_element(
+                        "ContractJourneyRouteVariants",
+                        8,
+                        "Variantes aux numéros des lignes autorisées",
+                        CALYPSO_FINAL_TYPE_UNKNOWN),
+                    make_calypso_final_element(
+                        "ContractJourneyRun", 16, "Référence du voyage", CALYPSO_FINAL_TYPE_UNKNOWN),
+                    make_calypso_final_element(
+                        "ContractJourneyVia", 16, "Code lieu du via", CALYPSO_FINAL_TYPE_UNKNOWN),
+                    make_calypso_final_element(
+                        "ContractJourneyDistance", 16, "Distance", CALYPSO_FINAL_TYPE_UNKNOWN),
+                    make_calypso_final_element(
+                        "ContractJourneyInterchanges",
+                        8,
+                        "Nombre de correspondances autorisées",
+                        CALYPSO_FINAL_TYPE_UNKNOWN),
+                }),
+
+            make_calypso_bitmap_element(
+                "ContractSaleData",
+                4,
+                (CalypsoElement[]){
+                    make_calypso_final_element(
+                        "ContractValiditySaleDate", 14, "Date de vente", CALYPSO_FINAL_TYPE_DATE),
+                    make_calypso_final_element(
+                        "ContractValiditySaleTime", 11, "Heure de vente", CALYPSO_FINAL_TYPE_TIME),
+                    make_calypso_final_element(
+                        "ContractValiditySaleAgent",
+                        8,
+                        "Identification de l’exploitant de vente",
+                        CALYPSO_FINAL_TYPE_SERVICE_PROVIDER),
+                    make_calypso_final_element(
+                        "ContractValiditySaleDevice",
+                        16,
+                        "Identification du terminal de vente",
+                        CALYPSO_FINAL_TYPE_UNKNOWN),
+                }),
+
+            make_calypso_final_element(
+                "ContractStatus", 8, "État du contrat", CALYPSO_FINAL_TYPE_UNKNOWN),
+
+            make_calypso_final_element(
+                "ContractLoyaltyPoints",
+                16,
+                "Nombre de points de fidélité",
+                CALYPSO_FINAL_TYPE_NUMBER),
+
+            make_calypso_final_element(
+                "ContractAuthenticator",
+                16,
+                "Code de contrôle de l’intégrité des données",
+                CALYPSO_FINAL_TYPE_UNKNOWN),
+
+            make_calypso_final_element(
+                "ContractData(0..255)", 0, "Données complémentaires", CALYPSO_FINAL_TYPE_UNKNOWN),
+        });
+
+    return NavigoContractStructure;
+}
+
+CalypsoApp* get_navigo_event_structure() {
+    CalypsoApp* NavigoEventStructure = malloc(sizeof(CalypsoApp));
+    if(!NavigoEventStructure) {
+        return NULL;
+    }
+
+    int app_elements_count = 3;
+
+    NavigoEventStructure->type = CALYPSO_APP_CONTRACT;
+    NavigoEventStructure->elements = malloc(app_elements_count * sizeof(CalypsoElement));
+    NavigoEventStructure->elements_size = app_elements_count;
+
+    NavigoEventStructure->elements[0] = make_calypso_final_element(
+        "EventDateStamp", 14, "Date de l’événement", CALYPSO_FINAL_TYPE_DATE);
+
+    NavigoEventStructure->elements[1] = make_calypso_final_element(
+        "EventTimeStamp", 11, "Heure de l’événement", CALYPSO_FINAL_TYPE_TIME);
+
+    NavigoEventStructure->elements[2] = make_calypso_bitmap_element(
+        "EventBitmap",
+        28,
+        (CalypsoElement[]){
+            make_calypso_final_element(
+                "EventDisplayData", 8, "Données pour l’affichage", CALYPSO_FINAL_TYPE_UNKNOWN),
+            make_calypso_final_element("EventNetworkId", 24, "Réseau", CALYPSO_FINAL_TYPE_NUMBER),
+            make_calypso_final_element(
+                "EventCode", 8, "Nature de l’événement", CALYPSO_FINAL_TYPE_UNKNOWN),
+            make_calypso_final_element(
+                "EventResult", 8, "Code Résultat", CALYPSO_FINAL_TYPE_UNKNOWN),
+            make_calypso_final_element(
+                "EventServiceProvider",
+                8,
+                "Identité de l’exploitant",
+                CALYPSO_FINAL_TYPE_SERVICE_PROVIDER),
+            make_calypso_final_element(
+                "EventNotokCounter", 8, "Compteur événements anormaux", CALYPSO_FINAL_TYPE_UNKNOWN),
+            make_calypso_final_element(
+                "EventSerialNumber",
+                24,
+                "Numéro de série de l’événement",
+                CALYPSO_FINAL_TYPE_UNKNOWN),
+            make_calypso_final_element(
+                "EventDestination", 16, "Destination de l’usager", CALYPSO_FINAL_TYPE_UNKNOWN),
+            make_calypso_final_element(
+                "EventLocationId", 16, "Lieu de l’événement", CALYPSO_FINAL_TYPE_UNKNOWN),
+            make_calypso_final_element(
+                "EventLocationGate", 8, "Identification du passage", CALYPSO_FINAL_TYPE_UNKNOWN),
+            make_calypso_final_element(
+                "EventDevice", 16, "Identificateur de l’équipement", CALYPSO_FINAL_TYPE_UNKNOWN),
+            make_calypso_final_element(
+                "EventRouteNumber", 16, "Référence de la ligne", CALYPSO_FINAL_TYPE_UNKNOWN),
+            make_calypso_final_element(
+                "EventRouteVariant",
+                8,
+                "Référence d’une variante de la ligne",
+                CALYPSO_FINAL_TYPE_UNKNOWN),
+            make_calypso_final_element(
+                "EventJourneyRun", 16, "Référence de la mission", CALYPSO_FINAL_TYPE_NUMBER),
+            make_calypso_final_element(
+                "EventVehicleId", 16, "Identificateur du véhicule", CALYPSO_FINAL_TYPE_NUMBER),
+            make_calypso_final_element(
+                "EventVehicleClass", 8, "Type de véhicule utilisé", CALYPSO_FINAL_TYPE_UNKNOWN),
+            make_calypso_final_element(
+                "EventLocationType",
+                5,
+                "Type d’endroit (gare, arrêt de bus), ",
+                CALYPSO_FINAL_TYPE_UNKNOWN),
+            make_calypso_final_element(
+                "EventEmployee", 240, "Code de l’employé impliqué", CALYPSO_FINAL_TYPE_UNKNOWN),
+            make_calypso_final_element(
+                "EventLocationReference",
+                16,
+                "Référence du lieu de l’événement",
+                CALYPSO_FINAL_TYPE_UNKNOWN),
+            make_calypso_final_element(
+                "EventJourneyInterchanges",
+                8,
+                "Nombre de correspondances",
+                CALYPSO_FINAL_TYPE_UNKNOWN),
+            make_calypso_final_element(
+                "EventPeriodJourneys", 16, "Nombre de voyage effectué", CALYPSO_FINAL_TYPE_UNKNOWN),
+            make_calypso_final_element(
+                "EventTotalJourneys",
+                16,
+                "Nombre total de voyage autorisé",
+                CALYPSO_FINAL_TYPE_UNKNOWN),
+            make_calypso_final_element(
+                "EventJourneyDistance", 16, "Distance parcourue", CALYPSO_FINAL_TYPE_UNKNOWN),
+            make_calypso_final_element(
+                "EventPriceAmount",
+                16,
+                "Montant en jeu lors de l’événement",
+                CALYPSO_FINAL_TYPE_UNKNOWN),
+            make_calypso_final_element(
+                "EventPriceUnit", 16, "Unité de montant en jeu", CALYPSO_FINAL_TYPE_UNKNOWN),
+            make_calypso_final_element(
+                "EventContractPointer",
+                5,
+                "Référence du contrat concerné",
+                CALYPSO_FINAL_TYPE_NUMBER),
+            make_calypso_final_element(
+                "EventAuthenticator", 16, "Code de sécurité", CALYPSO_FINAL_TYPE_UNKNOWN),
+
+            make_calypso_bitmap_element(
+                "EventData",
+                5,
+                (CalypsoElement[]){
+                    make_calypso_final_element(
+                        "EventDataDateFirstStamp",
+                        14,
+                        "Date de la première montée",
+                        CALYPSO_FINAL_TYPE_DATE),
+                    make_calypso_final_element(
+                        "EventDataTimeFirstStamp",
+                        11,
+                        "Heure de la première montée",
+                        CALYPSO_FINAL_TYPE_TIME),
+                    make_calypso_final_element(
+                        "EventDataSimulation",
+                        1,
+                        "Dernière validation (0=normal, 1=dégradé), ",
+                        CALYPSO_FINAL_TYPE_UNKNOWN),
+                    make_calypso_final_element(
+                        "EventDataTrip", 2, "Tronçon", CALYPSO_FINAL_TYPE_UNKNOWN),
+                    make_calypso_final_element(
+                        "EventDataRouteDirection", 2, "Sens", CALYPSO_FINAL_TYPE_UNKNOWN),
+                }),
+        });
+
+    return NavigoEventStructure;
+}

+ 10 - 0
metroflip/api/calypso/cards/navigo.h

@@ -0,0 +1,10 @@
+#include "../calypso_util.h"
+
+#ifndef NAVIGO_STRUCTURES_H
+#define NAVIGO_STRUCTURES_H
+
+CalypsoApp* get_navigo_contract_structure();
+
+CalypsoApp* get_navigo_event_structure();
+
+#endif // NAVIGO_STRUCTURES_H

+ 1322 - 0
metroflip/api/mosgortrans/mosgortrans_util.c

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

+ 22 - 0
metroflip/api/mosgortrans/mosgortrans_util.h

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

+ 19 - 14
metroflip/app/README.md

@@ -10,23 +10,28 @@ This is a list of metro cards and transit systems that are supported.
 
 
 ## Supported Cards
 ## Supported Cards
 - **Rav-Kav**  
 - **Rav-Kav**  
-  - Status: Partially supported
 - **Navigo**  
 - **Navigo**  
-  - Status: Fully supported
 - **Charliecard**  
 - **Charliecard**  
-  - Status: Fully supported
 - **Metromoney**  
 - **Metromoney**  
-  - Status: Fully supported
 - **Bip!**  
 - **Bip!**  
-  - Status: Fully supported
+- **Clipper**  
+- **Troika**  
+- **Myki**  
+- **Opal**  
+- **ITSO**
 
 
-More coming soon!
+More coming soon! 
 
 
-## Credits
-- App Author: luu176
-- Charliecard Parser: zacharyweiss
-- Rav-Kav Parser: luu176
-- Navigo Parser: luu176
-- Metromoney Parser: Leptopt1los
-- Bip! Parser: rbasoalto, gornekich
-- Info Slave: equipter
+## Credits:
+- **App Author**: luu176
+- **Charliecard Parser**: zacharyweiss
+- **Rav-Kav Parser**: luu176
+- **Navigo Parser**: luu176, DocSystem
+- **Metromoney Parser**: Leptopt1los
+- **Bip! Parser**: rbasoaltor & gornekich
+- **Clipper Parser**: ke6jjj
+- **Troika Parser**: gornekich
+- **Myki Parser**: gornekich
+- **Opal parser**: gornekich
+- **ITSO parser**: gsp8181, hedger, gornekich
+- **Info Slaves**: Equip, TheDingo8MyBaby

+ 1 - 1
metroflip/application.fam

@@ -5,7 +5,7 @@ App(
     entry_point="metroflip",
     entry_point="metroflip",
     stack_size=2 * 1024,
     stack_size=2 * 1024,
     fap_category="NFC",
     fap_category="NFC",
-    fap_version="0.2",
+    fap_version="0.4",
     fap_icon="icon.png",
     fap_icon="icon.png",
     fap_description="An implementation of metrodroid on the flipper",
     fap_description="An implementation of metrodroid on the flipper",
     fap_author="luu176",
     fap_author="luu176",

+ 4 - 2
metroflip/manifest.yml

@@ -8,13 +8,15 @@ name: 'Metroflip'
 screenshots:
 screenshots:
   - 'screenshots/Menu-Top.png'
   - 'screenshots/Menu-Top.png'
   - 'screenshots/Menu-Middle.png'
   - 'screenshots/Menu-Middle.png'
+  - 'screenshots/Navigo.png'
+  - 'screenshots/Navigo2.png'
   - 'screenshots/Rav-Kav.png'
   - 'screenshots/Rav-Kav.png'
   - 'screenshots/App.png'
   - 'screenshots/App.png'
 short_description: 'An implementation of Metrodroid on the Flipper Zero'
 short_description: 'An implementation of Metrodroid on the Flipper Zero'
 sourcecode:
 sourcecode:
   location:
   location:
-    commit_sha: 80b48d2a91698cfc47bc2d01059518eefc5ad4af
+    commit_sha: a5311068d33a2a49fc2f120940c3a6e4636855af
     origin: https://github.com/luu176/Metroflip
     origin: https://github.com/luu176/Metroflip
     subdir:
     subdir:
   type: git
   type: git
-version: 0.2
+version: 0.4.0

+ 9 - 0
metroflip/metroflip.c

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

+ 6 - 0
metroflip/metroflip_i.h

@@ -43,6 +43,8 @@ extern const Icon I_RFIDDolphinReceive_97x61;
 
 
 #include "scenes/metroflip_scene.h"
 #include "scenes/metroflip_scene.h"
 
 
+#include "scenes/navigo_structs.h"
+
 typedef struct {
 typedef struct {
     Gui* gui;
     Gui* gui;
     SceneManager* scene_manager;
     SceneManager* scene_manager;
@@ -71,6 +73,8 @@ typedef struct {
     char currency[4];
     char currency[4];
     char card_type[32];
     char card_type[32];
 
 
+    // Navigo specific context
+    NavigoContext* navigo_context;
 } Metroflip;
 } Metroflip;
 
 
 enum MetroflipCustomEvent {
 enum MetroflipCustomEvent {
@@ -122,6 +126,8 @@ void metroflip_app_blink_stop(Metroflip* metroflip);
     if(!(locked)) submenu_add_item(submenu, label, index, callback, callback_context)
     if(!(locked)) submenu_add_item(submenu, label, index, callback, callback_context)
 #endif
 #endif
 
 
+void metroflip_exit_widget_callback(GuiButtonType result, InputType type, void* context);
+
 ///////////////////////////////// Calypso /////////////////////////////////
 ///////////////////////////////// Calypso /////////////////////////////////
 
 
 #define Metroflip_POLLER_MAX_BUFFER_SIZE 1024
 #define Metroflip_POLLER_MAX_BUFFER_SIZE 1024

+ 1 - 10
metroflip/scenes/metroflip_scene_about.c

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

+ 1 - 10
metroflip/scenes/metroflip_scene_bip.c

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

+ 3 - 14
metroflip/scenes/metroflip_scene_charliecard.c

@@ -1127,10 +1127,8 @@ static bool charliecard_parse(FuriString* parsed_data, const MfClassicData* data
 
 
         const uint64_t key_a =
         const uint64_t key_a =
             bit_lib_bytes_to_num_be(sec_tr->key_a.data, COUNT_OF(sec_tr->key_a.data));
             bit_lib_bytes_to_num_be(sec_tr->key_a.data, COUNT_OF(sec_tr->key_a.data));
-        const uint64_t key_b =
-            bit_lib_bytes_to_num_be(sec_tr->key_b.data, COUNT_OF(sec_tr->key_b.data));
+
         if(key_a != charliecard_1k_keys[verify_sector].a) break;
         if(key_a != charliecard_1k_keys[verify_sector].a) break;
-        if(key_b != charliecard_1k_keys[verify_sector].b) break;
 
 
         // parse card data
         // parse card data
         const uint32_t card_number = mfg_sector_parse(data);
         const uint32_t card_number = mfg_sector_parse(data);
@@ -1183,15 +1181,6 @@ static bool charliecard_parse(FuriString* parsed_data, const MfClassicData* data
     return parsed;
     return parsed;
 }
 }
 
 
-void metroflip_charliecard_widget_callback(GuiButtonType result, InputType type, void* context) {
-    Metroflip* app = context;
-    UNUSED(result);
-
-    if(type == InputTypeShort) {
-        scene_manager_search_and_switch_to_previous_scene(app->scene_manager, MetroflipSceneStart);
-    }
-}
-
 static NfcCommand
 static NfcCommand
     metroflip_scene_charlicard_poller_callback(NfcGenericEvent event, void* context) {
     metroflip_scene_charlicard_poller_callback(NfcGenericEvent event, void* context) {
     furi_assert(context);
     furi_assert(context);
@@ -1243,7 +1232,7 @@ static NfcCommand
         widget_add_text_scroll_element(widget, 0, 0, 128, 64, furi_string_get_cstr(parsed_data));
         widget_add_text_scroll_element(widget, 0, 0, 128, 64, furi_string_get_cstr(parsed_data));
 
 
         widget_add_button_element(
         widget_add_button_element(
-            widget, GuiButtonTypeRight, "Exit", metroflip_charliecard_widget_callback, app);
+            widget, GuiButtonTypeRight, "Exit", metroflip_exit_widget_callback, app);
 
 
         furi_string_free(parsed_data);
         furi_string_free(parsed_data);
         view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewWidget);
         view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewWidget);
@@ -1251,7 +1240,7 @@ static NfcCommand
         metroflip_app_blink_stop(app);
         metroflip_app_blink_stop(app);
     } else if(mfc_event->type == MfClassicPollerEventTypeFail) {
     } else if(mfc_event->type == MfClassicPollerEventTypeFail) {
         FURI_LOG_I(TAG, "fail");
         FURI_LOG_I(TAG, "fail");
-        command = NfcCommandStop;
+        command = NfcCommandContinue;
     }
     }
 
 
     return command;
     return command;

+ 659 - 0
metroflip/scenes/metroflip_scene_clipper.c

@@ -0,0 +1,659 @@
+/*
+ * clipper.c - Parser for Clipper cards (San Francisco, California).
+ *
+ * Based on research, some of which dates to 2007!
+ *
+ * Copyright 2024 Jeremy Cooper <jeremy.gthb@baymoo.org>
+ *
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+#include <flipper_application.h>
+#include "../metroflip_i.h"
+#include <nfc/protocols/mf_desfire/mf_desfire_poller.h>
+
+#include <lib/nfc/protocols/mf_desfire/mf_desfire.h>
+
+#include <bit_lib.h>
+#include <datetime.h>
+#include <locale/locale.h>
+#include <inttypes.h>
+
+#define TAG "Metroflip:Scene:Clipper"
+
+//
+// Table of application ids observed in the wild, and their sources.
+//
+static const struct {
+    const MfDesfireApplicationId app;
+    const char* type;
+} clipper_types[] = {
+    // Application advertised on classic, plastic cards.
+    {.app = {.data = {0x90, 0x11, 0xf2}}, .type = "Card"},
+    // Application advertised on a mobile device.
+    {.app = {.data = {0x91, 0x11, 0xf2}}, .type = "Mobile Device"},
+};
+static const size_t kNumCardTypes = sizeof(clipper_types) / sizeof(clipper_types[0]);
+
+struct IdMapping_struct {
+    uint16_t id;
+    const char* name;
+};
+typedef struct IdMapping_struct IdMapping;
+
+#define COUNT(_array) sizeof(_array) / sizeof(_array[0])
+
+//
+// Known transportation agencies and their identifiers.
+//
+static const IdMapping agency_names[] = {
+    {.id = 0x0001, .name = "AC Transit"},
+    {.id = 0x0004, .name = "BART"},
+    {.id = 0x0006, .name = "Caltrain"},
+    {.id = 0x0008, .name = "CCTA"},
+    {.id = 0x000b, .name = "GGT"},
+    {.id = 0x000f, .name = "SamTrans"},
+    {.id = 0x0011, .name = "VTA"},
+    {.id = 0x0012, .name = "Muni"},
+    {.id = 0x0019, .name = "GG Ferry"},
+    {.id = 0x001b, .name = "SF Bay Ferry"},
+};
+static const size_t kNumAgencies = COUNT(agency_names);
+
+//
+// Known station names for various agencies.
+//
+static const IdMapping bart_zones[] = {
+    {.id = 0x0001, .name = "Colma"},
+    {.id = 0x0002, .name = "Daly City"},
+    {.id = 0x0003, .name = "Balboa Park"},
+    {.id = 0x0004, .name = "Glen Park"},
+    {.id = 0x0005, .name = "24th St Mission"},
+    {.id = 0x0006, .name = "16th St Mission"},
+    {.id = 0x0007, .name = "Civic Center/UN Plaza"},
+    {.id = 0x0008, .name = "Powell St"},
+    {.id = 0x0009, .name = "Montgomery St"},
+    {.id = 0x000a, .name = "Embarcadero"},
+    {.id = 0x000b, .name = "West Oakland"},
+    {.id = 0x000c, .name = "12th St/Oakland City Center"},
+    {.id = 0x000d, .name = "19th St/Oakland"},
+    {.id = 0x000e, .name = "MacArthur"},
+    {.id = 0x000f, .name = "Rockridge"},
+    {.id = 0x0010, .name = "Orinda"},
+    {.id = 0x0011, .name = "Lafayette"},
+    {.id = 0x0012, .name = "Walnut Creek"},
+    {.id = 0x0013, .name = "Pleasant Hill/Contra Costa Centre"},
+    {.id = 0x0014, .name = "Concord"},
+    {.id = 0x0015, .name = "North Concord/Martinez"},
+    {.id = 0x0016, .name = "Pittsburg/Bay Point"},
+    {.id = 0x0017, .name = "Ashby"},
+    {.id = 0x0018, .name = "Downtown Berkeley"},
+    {.id = 0x0019, .name = "North Berkeley"},
+    {.id = 0x001a, .name = "El Cerrito Plaza"},
+    {.id = 0x001b, .name = "El Cerrito Del Norte"},
+    {.id = 0x001c, .name = "Richmond"},
+    {.id = 0x001d, .name = "Lake Merrit"},
+    {.id = 0x001e, .name = "Fruitvale"},
+    {.id = 0x001f, .name = "Coliseum"},
+    {.id = 0x0021, .name = "San Leandro"},
+    {.id = 0x0022, .name = "Hayward"},
+    {.id = 0x0023, .name = "South Hayward"},
+    {.id = 0x0024, .name = "Union City"},
+    {.id = 0x0025, .name = "Fremont"},
+    {.id = 0x0026, .name = "Castro Valley"},
+    {.id = 0x0027, .name = "Dublin/Pleasanton"},
+    {.id = 0x0028, .name = "South San Francisco"},
+    {.id = 0x0029, .name = "San Bruno"},
+    {.id = 0x002a, .name = "SFO Airport"},
+    {.id = 0x002b, .name = "Millbrae"},
+    {.id = 0x002c, .name = "West Dublin/Pleasanton"},
+    {.id = 0x002d, .name = "OAK Airport"},
+    {.id = 0x002e, .name = "Warm Springs/South Fremont"},
+    {.id = 0x002f, .name = "Milpitas"},
+    {.id = 0x0030, .name = "Berryessa/North San Jose"},
+};
+static const size_t kNumBARTZones = COUNT(bart_zones);
+
+static const IdMapping muni_zones[] = {
+    {.id = 0x0000, .name = "City Street"},
+    {.id = 0x0005, .name = "Embarcadero"},
+    {.id = 0x0006, .name = "Montgomery"},
+    {.id = 0x0007, .name = "Powell"},
+    {.id = 0x0008, .name = "Civic Center"},
+    {.id = 0x0009, .name = "Van Ness"}, // Guessed
+    {.id = 0x000a, .name = "Church"},
+    {.id = 0x000b, .name = "Castro"},
+    {.id = 0x000c, .name = "Forest Hill"}, // Guessed
+    {.id = 0x000d, .name = "West Portal"},
+};
+static const size_t kNumMUNIZones = COUNT(muni_zones);
+
+static const IdMapping actransit_zones[] = {
+    {.id = 0x0000, .name = "City Street"},
+};
+static const size_t kNumACTransitZones = COUNT(actransit_zones);
+
+// Instead of persisting individual Station IDs, Caltrain saves Zone numbers.
+// https://www.caltrain.com/stations-zones
+static const IdMapping caltrain_zones[] = {
+    {.id = 0x0001, .name = "Zone 1"},
+    {.id = 0x0002, .name = "Zone 2"},
+    {.id = 0x0003, .name = "Zone 3"},
+    {.id = 0x0004, .name = "Zone 4"},
+    {.id = 0x0005, .name = "Zone 5"},
+    {.id = 0x0006, .name = "Zone 6"},
+};
+
+static const size_t kNumCaltrainZones = COUNT(caltrain_zones);
+
+//
+// Full agency+zone mapping.
+//
+static const struct {
+    uint16_t agency_id;
+    const IdMapping* zone_map;
+    size_t zone_count;
+} agency_zone_map[] = {
+    {.agency_id = 0x0001, .zone_map = actransit_zones, .zone_count = kNumACTransitZones},
+    {.agency_id = 0x0004, .zone_map = bart_zones, .zone_count = kNumBARTZones},
+    {.agency_id = 0x0006, .zone_map = caltrain_zones, .zone_count = kNumCaltrainZones},
+    {.agency_id = 0x0012, .zone_map = muni_zones, .zone_count = kNumMUNIZones}};
+static const size_t kNumAgencyZoneMaps = COUNT(agency_zone_map);
+
+// File ids of important files on the card.
+static const MfDesfireFileId clipper_ecash_file_id = 2;
+static const MfDesfireFileId clipper_histidx_file_id = 6;
+static const MfDesfireFileId clipper_identity_file_id = 8;
+static const MfDesfireFileId clipper_history_file_id = 14;
+
+struct ClipperCardInfo_struct {
+    uint32_t serial_number;
+    uint16_t counter;
+    uint16_t last_txn_id;
+    uint32_t last_updated_tm_1900;
+    uint16_t last_terminal_id;
+    int16_t balance_cents;
+};
+typedef struct ClipperCardInfo_struct ClipperCardInfo;
+
+// Forward declarations for helper functions.
+static void furi_string_cat_timestamp(
+    FuriString* str,
+    const char* date_hdr,
+    const char* time_hdr,
+    uint32_t tmst_1900);
+static bool get_file_contents(
+    const MfDesfireApplication* app,
+    const MfDesfireFileId* id,
+    MfDesfireFileType type,
+    size_t min_size,
+    const uint8_t** out);
+static bool decode_id_file(const uint8_t* ef8_data, ClipperCardInfo* info);
+static bool decode_cash_file(const uint8_t* ef2_data, ClipperCardInfo* info);
+static bool get_map_item(uint16_t id, const IdMapping* map, size_t sz, const char** out);
+static bool get_agency_zone_name(uint16_t agency_id, uint16_t zone_id, const char** out);
+static void
+    decode_usd(int16_t amount_cents, bool* out_is_negative, int16_t* out_usd, uint16_t* out_cents);
+static bool dump_ride_history(
+    const uint8_t* index_file,
+    const uint8_t* history_file,
+    size_t len,
+    FuriString* parsed_data);
+static bool dump_ride_event(const uint8_t* record, FuriString* parsed_data);
+
+// Unmarshal a 32-bit integer, big endian, unsigned
+static inline uint32_t get_u32be(const uint8_t* field) {
+    return bit_lib_bytes_to_num_be(field, 4);
+}
+
+// Unmarshal a 16-bit integer, big endian, unsigned
+static uint16_t get_u16be(const uint8_t* field) {
+    return bit_lib_bytes_to_num_be(field, 2);
+}
+
+// Unmarshal a 16-bit integer, big endian, signed, two's-complement
+static int16_t get_i16be(const uint8_t* field) {
+    uint16_t raw = get_u16be(field);
+    if(raw > 0x7fff)
+        return -((uint32_t)0x10000 - raw);
+    else
+        return raw;
+}
+
+static bool clipper_parse(const NfcDevice* device, FuriString* parsed_data) {
+    furi_assert(device);
+    furi_assert(parsed_data);
+
+    bool parsed = false;
+
+    do {
+        const MfDesfireData* data = nfc_device_get_data(device, NfcProtocolMfDesfire);
+
+        const MfDesfireApplication* app = NULL;
+        const char* device_description = NULL;
+
+        for(size_t i = 0; i < kNumCardTypes; i++) {
+            app = mf_desfire_get_application(data, &clipper_types[i].app);
+            device_description = clipper_types[i].type;
+            if(app != NULL) break;
+        }
+        // If no matching application was found, abort this parser.
+        if(app == NULL) break;
+        ClipperCardInfo info;
+        const uint8_t* id_data;
+        if(!get_file_contents(
+               app, &clipper_identity_file_id, MfDesfireFileTypeStandard, 5, &id_data))
+            break;
+        if(!decode_id_file(id_data, &info)) break;
+        const uint8_t* cash_data;
+        if(!get_file_contents(app, &clipper_ecash_file_id, MfDesfireFileTypeBackup, 32, &cash_data))
+            break;
+        if(!decode_cash_file(cash_data, &info)) break;
+        int16_t balance_usd;
+        uint16_t balance_cents;
+        bool _balance_is_negative;
+        decode_usd(info.balance_cents, &_balance_is_negative, &balance_usd, &balance_cents);
+        furi_string_cat_printf(
+            parsed_data,
+            "\e#Clipper\n"
+            "Serial: %" PRIu32 "\n"
+            "Balance: $%d.%02u\n"
+            "Type: %s\n"
+            "\e#Last Update\n",
+            info.serial_number,
+            balance_usd,
+            balance_cents,
+            device_description);
+        if(info.last_updated_tm_1900 != 0)
+            furi_string_cat_timestamp(
+                parsed_data, "Date: ", "\nTime: ", info.last_updated_tm_1900);
+        else
+            furi_string_cat_str(parsed_data, "Never");
+        furi_string_cat_printf(
+            parsed_data,
+            "\nTerminal: 0x%04x\n"
+            "Transaction Id: %u\n"
+            "Counter: %u\n",
+            info.last_terminal_id,
+            info.last_txn_id,
+            info.counter);
+
+        const uint8_t *history_index, *history;
+
+        if(!get_file_contents(
+               app, &clipper_histidx_file_id, MfDesfireFileTypeBackup, 16, &history_index))
+            break;
+        if(!get_file_contents(
+               app, &clipper_history_file_id, MfDesfireFileTypeStandard, 512, &history))
+            break;
+
+        if(!dump_ride_history(history_index, history, 512, parsed_data)) break;
+
+        parsed = true;
+    } while(false);
+
+    return parsed;
+}
+
+static bool get_file_contents(
+    const MfDesfireApplication* app,
+    const MfDesfireFileId* id,
+    MfDesfireFileType type,
+    size_t min_size,
+    const uint8_t** out) {
+    const MfDesfireFileSettings* settings = mf_desfire_get_file_settings(app, id);
+    if(settings == NULL) return false;
+    if(settings->type != type) return false;
+
+    const MfDesfireFileData* file_data = mf_desfire_get_file_data(app, id);
+
+    if(file_data == NULL) return false;
+
+    if(simple_array_get_count(file_data->data) < min_size) return false;
+
+    *out = simple_array_cget_data(file_data->data);
+
+    return true;
+}
+
+static bool decode_id_file(const uint8_t* ef8_data, ClipperCardInfo* info) {
+    // Identity file (8)
+    //
+    // Byte view
+    //
+    //       0    1    2    3    4    5    6    7    8
+    //       +----+----.----.----.----+----.----.----+
+    // 0x00  | uk | card_id           | unknown      |
+    //       +----+----.----.----.----+----.----.----+
+    // 0x08  | unknown                               |
+    //       +----.----.----.----.----.----.----.----+
+    // 0x10    ...
+    //
+    //
+    // Field          Datatype   Description
+    // -----          --------   -----------
+    // uk             ?8??       Unknown, 8-bit byte
+    // card_id        U32BE      Card identifier
+    //
+    info->serial_number = bit_lib_bytes_to_num_be(&ef8_data[1], 4);
+    return true;
+}
+
+static bool decode_cash_file(const uint8_t* ef2_data, ClipperCardInfo* info) {
+    // ECash file (2)
+    //
+    // Byte view
+    //
+    //       0    1    2    3    4    5    6    7    8
+    //       +----.----+----.----+----.----.----.----+
+    // 0x00  |  unk00  | counter | timestamp_1900    |
+    //       +----.----+----.----+----.----.----.----+
+    // 0x08  | term_id |     unk01                   |
+    //       +----.----+----.----+----.----.----.----+
+    // 0x10  | txn_id  | balance |      unknown      |
+    //       +----.----+----.----+----.----.----.----+
+    // 0x18  |               unknown                 |
+    //       +---------------------------------------+
+    //
+    // Field          Datatype Description
+    // -----          -------- -----------
+    // unk00          U8[2]     Unknown bytes
+    // counter        U16BE     Unknown, appears to be a counter
+    // timestamp_1900 U32BE     Timestamp of last transaction, in seconds
+    //                          since 1900-01-01 GMT.
+    // unk01          U8[6]     Unknown bytes
+    // txn_id         U16BE     Id of last transaction.
+    // balance        S16BE     Card cash balance, in cents.
+    //                          Cards can obtain negative balances in this
+    //                          system, so balances are signed integers.
+    //                          Maximum card balance is therefore
+    //                          $327.67.
+    // unk02          U8[12]    Unknown bytes.
+    //
+    info->counter = get_u16be(&ef2_data[2]);
+    info->last_updated_tm_1900 = get_u32be(&ef2_data[4]);
+    info->last_terminal_id = get_u16be(&ef2_data[8]);
+    info->last_txn_id = get_u16be(&ef2_data[0x10]);
+    info->balance_cents = get_i16be(&ef2_data[0x12]);
+    return true;
+}
+
+static bool dump_ride_history(
+    const uint8_t* index_file,
+    const uint8_t* history_file,
+    size_t len,
+    FuriString* parsed_data) {
+    static const size_t kRideRecordSize = 0x20;
+
+    for(size_t i = 0; i < 16; i++) {
+        uint8_t record_num = index_file[i];
+        if(record_num == 0xff) break;
+
+        size_t record_offset = record_num * kRideRecordSize;
+
+        if(record_offset + kRideRecordSize > len) break;
+
+        const uint8_t* record = &history_file[record_offset];
+        if(!dump_ride_event(record, parsed_data)) break;
+    }
+
+    return true;
+}
+
+static bool dump_ride_event(const uint8_t* record, FuriString* parsed_data) {
+    // Ride record
+    //
+    //       0    1    2    3    4    5    6    7    8
+    //       +----+----+----.----+----.----+----.----+
+    // 0x00  |0x10| ?  | agency  | ?       | fare    |
+    //       +----.----+----.----+----.----.----.----+
+    // 0x08  | ?       | vehicle | time_on           |
+    //       +----.----.----.----+----.----+----.----+
+    // 0x10  | time_off          | zone_on | zone_off|
+    //       +----+----.----.----.----+----+----+----+
+    // 0x18  | ?  | ?                 | ?  | ?  | ?  |
+    //       +----+----.----.----.----+----+----+----+
+    //
+    // Field          Datatype Description
+    // -----          -------- -----------
+    // agency         U16BE    Transportation agency identifier.
+    //                         Known ids:
+    //                         1  == AC Transit
+    //                         4  == BART
+    //                         18 == SF MUNI
+    // fare           I16BE    Fare deducted, in cents.
+    // vehicle        U16BE    Vehicle id (0 == not provided)
+    // time_on        U32BE    Boarding time, in seconds since 1900-01-01 GMT.
+    // time_off       U32BE    Off-boarding time, if present, in seconds
+    //                         since 1900-01-01 GMT. Set to zero if no offboard
+    //                         has been recorded.
+    // zone_on        U16BE    Id of boarding zone or station. Agency-specific.
+    // zone_off       U16BE    Id of offboarding zone or station. Agency-
+    //                         specific.
+    if(record[0] != 0x10) return false;
+
+    uint16_t agency_id = get_u16be(&record[2]);
+    if(agency_id == 0)
+        // Likely empty record. Skip.
+        return false;
+    const char* agency_name;
+    bool ok = get_map_item(agency_id, agency_names, kNumAgencies, &agency_name);
+    if(!ok) agency_name = "Unknown";
+
+    uint16_t vehicle_id = get_u16be(&record[0x0a]);
+
+    int16_t fare_raw_cents = get_i16be(&record[6]);
+    bool _fare_is_negative;
+    int16_t fare_usd;
+    uint16_t fare_cents;
+    decode_usd(fare_raw_cents, &_fare_is_negative, &fare_usd, &fare_cents);
+
+    uint32_t time_on_raw = get_u32be(&record[0x0c]);
+    uint32_t time_off_raw = get_u32be(&record[0x10]);
+    uint16_t zone_id_on = get_u16be(&record[0x14]);
+    uint16_t zone_id_off = get_u16be(&record[0x16]);
+
+    const char *zone_on, *zone_off;
+    if(!get_agency_zone_name(agency_id, zone_id_on, &zone_on)) {
+        zone_on = "Unknown";
+    }
+    if(!get_agency_zone_name(agency_id, zone_id_off, &zone_off)) {
+        zone_off = "Unknown";
+    }
+
+    furi_string_cat_str(parsed_data, "\e#Ride Record\n");
+    furi_string_cat_timestamp(parsed_data, "Date: ", "\nTime: ", time_on_raw);
+    furi_string_cat_printf(
+        parsed_data,
+        "\n"
+        "Fare: $%d.%02u\n"
+        "Agency: %s (%04x)\n"
+        "On: %s (%04x)\n",
+        fare_usd,
+        fare_cents,
+        agency_name,
+        agency_id,
+        zone_on,
+        zone_id_on);
+    if(vehicle_id != 0) {
+        furi_string_cat_printf(parsed_data, "Vehicle id: %d\n", vehicle_id);
+    }
+    if(time_off_raw != 0) {
+        furi_string_cat_printf(parsed_data, "Off: %s (%04x)\n", zone_off, zone_id_off);
+        furi_string_cat_timestamp(parsed_data, "Date Off: ", "\nTime Off: ", time_off_raw);
+        furi_string_cat_str(parsed_data, "\n");
+    }
+
+    return true;
+}
+
+static bool get_map_item(uint16_t id, const IdMapping* map, size_t sz, const char** out) {
+    for(size_t i = 0; i < sz; i++) {
+        if(map[i].id == id) {
+            *out = map[i].name;
+            return true;
+        }
+    }
+
+    return false;
+}
+
+static bool get_agency_zone_name(uint16_t agency_id, uint16_t zone_id, const char** out) {
+    for(size_t i = 0; i < kNumAgencyZoneMaps; i++) {
+        if(agency_zone_map[i].agency_id == agency_id) {
+            return get_map_item(
+                zone_id, agency_zone_map[i].zone_map, agency_zone_map[i].zone_count, out);
+        }
+    }
+
+    return false;
+}
+
+// Split a balance/fare amount from raw cents to dollars and cents portion,
+// automatically adjusting the cents portion so that it is always positive,
+// for easier display.
+static void
+    decode_usd(int16_t amount_cents, bool* out_is_negative, int16_t* out_usd, uint16_t* out_cents) {
+    *out_usd = amount_cents / 100;
+
+    if(amount_cents >= 0) {
+        *out_is_negative = false;
+        *out_cents = amount_cents % 100;
+    } else {
+        *out_is_negative = true;
+        *out_cents = (amount_cents * -1) % 100;
+    }
+}
+
+// Decode a raw 1900-based timestamp and append a human-readable form to a
+// FuriString.
+static void furi_string_cat_timestamp(
+    FuriString* str,
+    const char* date_hdr,
+    const char* time_hdr,
+    uint32_t tmst_1900) {
+    DateTime tm;
+
+    datetime_timestamp_to_datetime(tmst_1900, &tm);
+
+    FuriString* date_str = furi_string_alloc();
+    locale_format_date(date_str, &tm, locale_get_date_format(), "-");
+
+    FuriString* time_str = furi_string_alloc();
+    locale_format_time(time_str, &tm, locale_get_time_format(), true);
+
+    furi_string_cat_printf(
+        str,
+        "%s%s%s%s (UTC)",
+        date_hdr,
+        furi_string_get_cstr(date_str),
+        time_hdr,
+        furi_string_get_cstr(time_str));
+
+    furi_string_free(date_str);
+    furi_string_free(time_str);
+}
+
+static NfcCommand metroflip_scene_clipper_poller_callback(NfcGenericEvent event, void* context) {
+    furi_assert(event.protocol == NfcProtocolMfDesfire);
+
+    Metroflip* app = context;
+    NfcCommand command = NfcCommandContinue;
+
+    FuriString* parsed_data = furi_string_alloc();
+    Widget* widget = app->widget;
+    furi_string_reset(app->text_box_store);
+    const MfDesfirePollerEvent* mf_desfire_event = event.event_data;
+    if(mf_desfire_event->type == MfDesfirePollerEventTypeReadSuccess) {
+        nfc_device_set_data(
+            app->nfc_device, NfcProtocolMfDesfire, nfc_poller_get_data(app->poller));
+        if(!clipper_parse(app->nfc_device, parsed_data)) {
+            furi_string_reset(app->text_box_store);
+            FURI_LOG_I(TAG, "Unknown card type");
+            furi_string_printf(parsed_data, "\e#Unknown card\n");
+        }
+        widget_add_text_scroll_element(widget, 0, 0, 128, 64, furi_string_get_cstr(parsed_data));
+
+        widget_add_button_element(
+            widget, GuiButtonTypeRight, "Exit", metroflip_exit_widget_callback, app);
+
+        furi_string_free(parsed_data);
+        view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewWidget);
+        metroflip_app_blink_stop(app);
+        command = NfcCommandStop;
+    } else if(mf_desfire_event->type == MfDesfirePollerEventTypeReadFailed) {
+        view_dispatcher_send_custom_event(app->view_dispatcher, MetroflipCustomEventPollerSuccess);
+        command = NfcCommandContinue;
+    }
+
+    return command;
+}
+
+void metroflip_scene_clipper_on_enter(void* context) {
+    Metroflip* app = context;
+    dolphin_deed(DolphinDeedNfcRead);
+
+    // Setup view
+    Popup* popup = app->popup;
+    popup_set_header(popup, "Apply\n card to\nthe back", 68, 30, AlignLeft, AlignTop);
+    popup_set_icon(popup, 0, 3, &I_RFIDDolphinReceive_97x61);
+
+    // Start worker
+    view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewPopup);
+    nfc_scanner_alloc(app->nfc);
+    app->poller = nfc_poller_alloc(app->nfc, NfcProtocolMfDesfire);
+    nfc_poller_start(app->poller, metroflip_scene_clipper_poller_callback, app);
+
+    metroflip_app_blink_start(app);
+}
+
+bool metroflip_scene_clipper_on_event(void* context, SceneManagerEvent event) {
+    Metroflip* app = context;
+    bool consumed = false;
+
+    if(event.type == SceneManagerEventTypeCustom) {
+        if(event.event == MetroflipCustomEventCardDetected) {
+            Popup* popup = app->popup;
+            popup_set_header(popup, "DON'T\nMOVE", 68, 30, AlignLeft, AlignTop);
+            consumed = true;
+        } else if(event.event == MetroflipCustomEventCardLost) {
+            Popup* popup = app->popup;
+            popup_set_header(popup, "Card \n lost", 68, 30, AlignLeft, AlignTop);
+            consumed = true;
+        } else if(event.event == MetroflipCustomEventWrongCard) {
+            Popup* popup = app->popup;
+            popup_set_header(popup, "WRONG \n CARD", 68, 30, AlignLeft, AlignTop);
+            consumed = true;
+        } else if(event.event == MetroflipCustomEventPollerFail) {
+            Popup* popup = app->popup;
+            popup_set_header(popup, "Failed", 68, 30, AlignLeft, AlignTop);
+            consumed = true;
+        }
+    } else if(event.type == SceneManagerEventTypeBack) {
+        scene_manager_search_and_switch_to_previous_scene(app->scene_manager, MetroflipSceneStart);
+        consumed = true;
+    }
+
+    return consumed;
+}
+
+void metroflip_scene_clipper_on_exit(void* context) {
+    Metroflip* app = context;
+    widget_reset(app->widget);
+    metroflip_app_blink_stop(app);
+
+    if(app->poller) {
+        nfc_poller_stop(app->poller);
+        nfc_poller_free(app->poller);
+    }
+}

+ 5 - 0
metroflip/scenes/metroflip_scene_config.h

@@ -2,8 +2,13 @@ ADD_SCENE(metroflip, start, Start)
 ADD_SCENE(metroflip, ravkav, RavKav)
 ADD_SCENE(metroflip, ravkav, RavKav)
 ADD_SCENE(metroflip, navigo, Navigo)
 ADD_SCENE(metroflip, navigo, Navigo)
 ADD_SCENE(metroflip, charliecard, CharlieCard)
 ADD_SCENE(metroflip, charliecard, CharlieCard)
+ADD_SCENE(metroflip, clipper, Clipper)
 ADD_SCENE(metroflip, metromoney, Metromoney)
 ADD_SCENE(metroflip, metromoney, Metromoney)
 ADD_SCENE(metroflip, read_success, ReadSuccess)
 ADD_SCENE(metroflip, read_success, ReadSuccess)
 ADD_SCENE(metroflip, bip, Bip)
 ADD_SCENE(metroflip, bip, Bip)
+ADD_SCENE(metroflip, myki, Myki)
+ADD_SCENE(metroflip, troika, Troika)
+ADD_SCENE(metroflip, opal, Opal)
+ADD_SCENE(metroflip, itso, Itso)
 ADD_SCENE(metroflip, about, About)
 ADD_SCENE(metroflip, about, About)
 ADD_SCENE(metroflip, credits, Credits)
 ADD_SCENE(metroflip, credits, Credits)

+ 8 - 11
metroflip/scenes/metroflip_scene_credits.c

@@ -3,15 +3,6 @@
 
 
 #define TAG "Metroflip:Scene:Credits"
 #define TAG "Metroflip:Scene:Credits"
 
 
-void metroflip_credits_widget_callback(GuiButtonType result, InputType type, void* context) {
-    Metroflip* app = context;
-    UNUSED(result);
-
-    if(type == InputTypeShort) {
-        scene_manager_search_and_switch_to_previous_scene(app->scene_manager, MetroflipSceneStart);
-    }
-}
-
 void metroflip_scene_credits_on_enter(void* context) {
 void metroflip_scene_credits_on_enter(void* context) {
     Metroflip* app = context;
     Metroflip* app = context;
     Widget* widget = app->widget;
     Widget* widget = app->widget;
@@ -26,15 +17,21 @@ void metroflip_scene_credits_on_enter(void* context) {
     furi_string_cat_printf(str, "Inspired by Metrodroid\n\n");
     furi_string_cat_printf(str, "Inspired by Metrodroid\n\n");
     furi_string_cat_printf(str, "\e#Parser Credits:\n\n");
     furi_string_cat_printf(str, "\e#Parser Credits:\n\n");
     furi_string_cat_printf(str, "Rav-Kav Parser: luu176\n\n");
     furi_string_cat_printf(str, "Rav-Kav Parser: luu176\n\n");
+    furi_string_cat_printf(str, "Navigo Parser: \n luu176, DocSystem \n\n");
     furi_string_cat_printf(str, "Metromoney Parser:\n Leptopt1los\n\n");
     furi_string_cat_printf(str, "Metromoney Parser:\n Leptopt1los\n\n");
     furi_string_cat_printf(str, "Bip! Parser:\n rbasoalto, gornekich\n\n");
     furi_string_cat_printf(str, "Bip! Parser:\n rbasoalto, gornekich\n\n");
     furi_string_cat_printf(str, "CharlieCard Parser:\n zacharyweiss\n\n");
     furi_string_cat_printf(str, "CharlieCard Parser:\n zacharyweiss\n\n");
-    furi_string_cat_printf(str, "Info Slave: equip\n\n");
+    furi_string_cat_printf(str, "Clipper Parser:\n ke6jjj\n\n");
+    furi_string_cat_printf(str, "Troika Parser:\n gornekich\n\n");
+    furi_string_cat_printf(str, "ITSO Parser:\n gsp8181, hedger, gornekich\n\n");
+    furi_string_cat_printf(str, "Opal Parser:\n gornekich\n\n");
+    furi_string_cat_printf(str, "myki Parser:\n gornekich\n\n");
+    furi_string_cat_printf(str, "Info Slaves:\n Equip, TheDingo8MyBaby\n\n");
 
 
     widget_add_text_scroll_element(widget, 0, 0, 128, 64, furi_string_get_cstr(str));
     widget_add_text_scroll_element(widget, 0, 0, 128, 64, furi_string_get_cstr(str));
 
 
     widget_add_button_element(
     widget_add_button_element(
-        widget, GuiButtonTypeRight, "Exit", metroflip_credits_widget_callback, app);
+        widget, GuiButtonTypeRight, "Exit", metroflip_exit_widget_callback, app);
 
 
     furi_string_free(str);
     furi_string_free(str);
     view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewWidget);
     view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewWidget);

+ 205 - 0
metroflip/scenes/metroflip_scene_itso.c

@@ -0,0 +1,205 @@
+/* itso.c - Parser for ITSO cards (United Kingdom). */
+#include "../metroflip_i.h"
+#include <flipper_application.h>
+
+#include <lib/nfc/protocols/mf_desfire/mf_desfire.h>
+#include <lib/nfc/protocols/mf_desfire/mf_desfire_poller.h>
+#include <lib/toolbox/strint.h>
+
+#include <applications/services/locale/locale.h>
+#include <datetime.h>
+
+#define TAG "Metroflip:Scene:ITSO"
+
+static const MfDesfireApplicationId itso_app_id = {.data = {0x16, 0x02, 0xa0}};
+static const MfDesfireFileId itso_file_id = 0x0f;
+
+int64_t swap_int64(int64_t val) {
+    val = ((val << 8) & 0xFF00FF00FF00FF00ULL) | ((val >> 8) & 0x00FF00FF00FF00FFULL);
+    val = ((val << 16) & 0xFFFF0000FFFF0000ULL) | ((val >> 16) & 0x0000FFFF0000FFFFULL);
+    return (val << 32) | ((val >> 32) & 0xFFFFFFFFULL);
+}
+
+uint64_t swap_uint64(uint64_t val) {
+    val = ((val << 8) & 0xFF00FF00FF00FF00ULL) | ((val >> 8) & 0x00FF00FF00FF00FFULL);
+    val = ((val << 16) & 0xFFFF0000FFFF0000ULL) | ((val >> 16) & 0x0000FFFF0000FFFFULL);
+    return (val << 32) | (val >> 32);
+}
+
+static bool itso_parse(const NfcDevice* device, FuriString* parsed_data) {
+    furi_assert(device);
+    furi_assert(parsed_data);
+
+    bool parsed = false;
+
+    do {
+        const MfDesfireData* data = nfc_device_get_data(device, NfcProtocolMfDesfire);
+
+        const MfDesfireApplication* app = mf_desfire_get_application(data, &itso_app_id);
+        if(app == NULL) break;
+
+        typedef struct {
+            uint64_t part1;
+            uint64_t part2;
+            uint64_t part3;
+            uint64_t part4;
+        } ItsoFile;
+
+        const MfDesfireFileSettings* file_settings =
+            mf_desfire_get_file_settings(app, &itso_file_id);
+
+        if(file_settings == NULL || file_settings->type != MfDesfireFileTypeStandard ||
+           file_settings->data.size < sizeof(ItsoFile))
+            break;
+
+        const MfDesfireFileData* file_data = mf_desfire_get_file_data(app, &itso_file_id);
+        if(file_data == NULL) break;
+
+        const ItsoFile* itso_file = simple_array_cget_data(file_data->data);
+
+        uint64_t x1 = swap_uint64(itso_file->part1);
+        uint64_t x2 = swap_uint64(itso_file->part2);
+
+        char cardBuff[32];
+        char dateBuff[18];
+
+        snprintf(cardBuff, sizeof(cardBuff), "%llx%llx", x1, x2);
+        snprintf(dateBuff, sizeof(dateBuff), "%llx", x2);
+
+        char* cardp = cardBuff + 4;
+        cardp[18] = '\0';
+
+        // All itso card numbers are prefixed with "633597"
+        if(strncmp(cardp, "633597", 6) != 0) break;
+
+        char* datep = dateBuff + 12;
+        dateBuff[17] = '\0';
+
+        // DateStamp is defined in BS EN 1545 - Days passed since 01/01/1997
+        uint32_t dateStamp;
+        if(strint_to_uint32(datep, NULL, &dateStamp, 16) != StrintParseNoError) {
+            return false;
+        }
+        uint32_t unixTimestamp = dateStamp * 24 * 60 * 60 + 852076800U;
+
+        furi_string_set(parsed_data, "\e#ITSO Card\n");
+
+        // Digit count in each space-separated group
+        static const uint8_t digit_count[] = {6, 4, 4, 4};
+
+        for(uint32_t i = 0, k = 0; i < COUNT_OF(digit_count); k += digit_count[i++]) {
+            for(uint32_t j = 0; j < digit_count[i]; ++j) {
+                furi_string_push_back(parsed_data, cardp[j + k]);
+            }
+            furi_string_push_back(parsed_data, ' ');
+        }
+
+        DateTime timestamp = {0};
+        datetime_timestamp_to_datetime(unixTimestamp, &timestamp);
+
+        FuriString* timestamp_str = furi_string_alloc();
+        locale_format_date(timestamp_str, &timestamp, locale_get_date_format(), "-");
+
+        furi_string_cat(parsed_data, "\nExpiry: ");
+        furi_string_cat(parsed_data, timestamp_str);
+
+        furi_string_free(timestamp_str);
+
+        parsed = true;
+    } while(false);
+
+    return parsed;
+}
+
+static NfcCommand metroflip_scene_itso_poller_callback(NfcGenericEvent event, void* context) {
+    furi_assert(event.protocol == NfcProtocolMfDesfire);
+
+    Metroflip* app = context;
+    NfcCommand command = NfcCommandContinue;
+
+    FuriString* parsed_data = furi_string_alloc();
+    Widget* widget = app->widget;
+    furi_string_reset(app->text_box_store);
+    const MfDesfirePollerEvent* mf_desfire_event = event.event_data;
+    if(mf_desfire_event->type == MfDesfirePollerEventTypeReadSuccess) {
+        nfc_device_set_data(
+            app->nfc_device, NfcProtocolMfDesfire, nfc_poller_get_data(app->poller));
+        if(!itso_parse(app->nfc_device, parsed_data)) {
+            furi_string_reset(app->text_box_store);
+            FURI_LOG_I(TAG, "Unknown card type");
+            furi_string_printf(parsed_data, "\e#Unknown card\n");
+        }
+        widget_add_text_scroll_element(widget, 0, 0, 128, 64, furi_string_get_cstr(parsed_data));
+
+        widget_add_button_element(
+            widget, GuiButtonTypeRight, "Exit", metroflip_exit_widget_callback, app);
+
+        furi_string_free(parsed_data);
+        view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewWidget);
+        metroflip_app_blink_stop(app);
+        command = NfcCommandStop;
+    } else if(mf_desfire_event->type == MfDesfirePollerEventTypeReadFailed) {
+        view_dispatcher_send_custom_event(app->view_dispatcher, MetroflipCustomEventPollerSuccess);
+        command = NfcCommandContinue;
+    }
+
+    return command;
+}
+
+void metroflip_scene_itso_on_enter(void* context) {
+    Metroflip* app = context;
+    dolphin_deed(DolphinDeedNfcRead);
+
+    // Setup view
+    Popup* popup = app->popup;
+    popup_set_header(popup, "Apply\n card to\nthe back", 68, 30, AlignLeft, AlignTop);
+    popup_set_icon(popup, 0, 3, &I_RFIDDolphinReceive_97x61);
+
+    // Start worker
+    view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewPopup);
+    nfc_scanner_alloc(app->nfc);
+    app->poller = nfc_poller_alloc(app->nfc, NfcProtocolMfDesfire);
+    nfc_poller_start(app->poller, metroflip_scene_itso_poller_callback, app);
+
+    metroflip_app_blink_start(app);
+}
+
+bool metroflip_scene_itso_on_event(void* context, SceneManagerEvent event) {
+    Metroflip* app = context;
+    bool consumed = false;
+
+    if(event.type == SceneManagerEventTypeCustom) {
+        if(event.event == MetroflipCustomEventCardDetected) {
+            Popup* popup = app->popup;
+            popup_set_header(popup, "DON'T\nMOVE", 68, 30, AlignLeft, AlignTop);
+            consumed = true;
+        } else if(event.event == MetroflipCustomEventCardLost) {
+            Popup* popup = app->popup;
+            popup_set_header(popup, "Card \n lost", 68, 30, AlignLeft, AlignTop);
+            consumed = true;
+        } else if(event.event == MetroflipCustomEventWrongCard) {
+            Popup* popup = app->popup;
+            popup_set_header(popup, "WRONG \n CARD", 68, 30, AlignLeft, AlignTop);
+            consumed = true;
+        } else if(event.event == MetroflipCustomEventPollerFail) {
+            Popup* popup = app->popup;
+            popup_set_header(popup, "Failed", 68, 30, AlignLeft, AlignTop);
+            consumed = true;
+        }
+    } else if(event.type == SceneManagerEventTypeBack) {
+        scene_manager_search_and_switch_to_previous_scene(app->scene_manager, MetroflipSceneStart);
+        consumed = true;
+    }
+
+    return consumed;
+}
+
+void metroflip_scene_itso_on_exit(void* context) {
+    Metroflip* app = context;
+    widget_reset(app->widget);
+    metroflip_app_blink_stop(app);
+    if(app->poller) {
+        nfc_poller_stop(app->poller);
+        nfc_poller_free(app->poller);
+    }
+}

+ 188 - 0
metroflip/scenes/metroflip_scene_myki.c

@@ -0,0 +1,188 @@
+#include <flipper_application.h>
+
+#include <lib/nfc/protocols/mf_desfire/mf_desfire.h>
+#include <stdio.h>
+
+#include "../metroflip_i.h"
+#include <nfc/protocols/mf_desfire/mf_desfire_poller.h>
+
+#define TAG "Metroflip:Scene:myki"
+
+static const MfDesfireApplicationId myki_app_id = {.data = {0x00, 0x11, 0xf2}};
+static const MfDesfireFileId myki_file_id = 0x0f;
+
+static uint8_t myki_calculate_luhn(uint64_t number) {
+    // https://en.wikipedia.org/wiki/Luhn_algorithm
+    // Drop existing check digit to form payload
+    uint64_t payload = number / 10;
+    int sum = 0;
+    int position = 0;
+
+    while(payload > 0) {
+        int digit = payload % 10;
+        if(position % 2 == 0) {
+            digit *= 2;
+        }
+        if(digit > 9) {
+            digit = (digit / 10) + (digit % 10);
+        }
+        sum += digit;
+        payload /= 10;
+        position++;
+    }
+
+    return (10 - (sum % 10)) % 10;
+}
+
+static bool myki_parse(const NfcDevice* device, FuriString* parsed_data) {
+    furi_assert(device);
+    furi_assert(parsed_data);
+
+    bool parsed = false;
+
+    do {
+        const MfDesfireData* data = nfc_device_get_data(device, NfcProtocolMfDesfire);
+
+        const MfDesfireApplication* app = mf_desfire_get_application(data, &myki_app_id);
+        if(app == NULL) break;
+
+        typedef struct {
+            uint32_t top;
+            uint32_t bottom;
+        } mykiFile;
+
+        const MfDesfireFileSettings* file_settings =
+            mf_desfire_get_file_settings(app, &myki_file_id);
+
+        if(file_settings == NULL || file_settings->type != MfDesfireFileTypeStandard ||
+           file_settings->data.size < sizeof(mykiFile))
+            break;
+
+        const MfDesfireFileData* file_data = mf_desfire_get_file_data(app, &myki_file_id);
+        if(file_data == NULL) break;
+
+        const mykiFile* myki_file = simple_array_cget_data(file_data->data);
+
+        // All myki card numbers are prefixed with "308425"
+        if(myki_file->top != 308425UL) break;
+        // Card numbers are always 15 digits in length
+        if(myki_file->bottom < 10000000UL || myki_file->bottom >= 100000000UL) break;
+
+        uint64_t card_number = myki_file->top * 1000000000ULL + myki_file->bottom * 10UL;
+        // Stored card number doesn't include check digit
+        card_number += myki_calculate_luhn(card_number);
+
+        furi_string_set(parsed_data, "\e#myki\nNo.: ");
+
+        // Stylise card number according to the physical card
+        char card_string[20];
+        snprintf(card_string, sizeof(card_string), "%llu", card_number);
+
+        // Digit count in each space-separated group
+        static const uint8_t digit_count[] = {1, 5, 4, 4, 1};
+
+        for(uint32_t i = 0, k = 0; i < COUNT_OF(digit_count); k += digit_count[i++]) {
+            for(uint32_t j = 0; j < digit_count[i]; ++j) {
+                furi_string_push_back(parsed_data, card_string[j + k]);
+            }
+            furi_string_push_back(parsed_data, ' ');
+        }
+
+        parsed = true;
+    } while(false);
+
+    return parsed;
+}
+
+static NfcCommand metroflip_scene_myki_poller_callback(NfcGenericEvent event, void* context) {
+    furi_assert(event.protocol == NfcProtocolMfDesfire);
+
+    Metroflip* app = context;
+    NfcCommand command = NfcCommandContinue;
+
+    FuriString* parsed_data = furi_string_alloc();
+    Widget* widget = app->widget;
+    furi_string_reset(app->text_box_store);
+    const MfDesfirePollerEvent* mf_desfire_event = event.event_data;
+    if(mf_desfire_event->type == MfDesfirePollerEventTypeReadSuccess) {
+        nfc_device_set_data(
+            app->nfc_device, NfcProtocolMfDesfire, nfc_poller_get_data(app->poller));
+        if(!myki_parse(app->nfc_device, parsed_data)) {
+            furi_string_reset(app->text_box_store);
+            FURI_LOG_I(TAG, "Unknown card type");
+            furi_string_printf(parsed_data, "\e#Unknown card\n");
+        }
+        widget_add_text_scroll_element(widget, 0, 0, 128, 64, furi_string_get_cstr(parsed_data));
+
+        widget_add_button_element(
+            widget, GuiButtonTypeRight, "Exit", metroflip_exit_widget_callback, app);
+
+        furi_string_free(parsed_data);
+        view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewWidget);
+        metroflip_app_blink_stop(app);
+        command = NfcCommandStop;
+    } else if(mf_desfire_event->type == MfDesfirePollerEventTypeReadFailed) {
+        view_dispatcher_send_custom_event(app->view_dispatcher, MetroflipCustomEventPollerSuccess);
+        command = NfcCommandContinue;
+    }
+
+    return command;
+}
+
+void metroflip_scene_myki_on_enter(void* context) {
+    Metroflip* app = context;
+    dolphin_deed(DolphinDeedNfcRead);
+
+    // Setup view
+    Popup* popup = app->popup;
+    popup_set_header(popup, "Apply\n card to\nthe back", 68, 30, AlignLeft, AlignTop);
+    popup_set_icon(popup, 0, 3, &I_RFIDDolphinReceive_97x61);
+
+    // Start worker
+    view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewPopup);
+    nfc_scanner_alloc(app->nfc);
+    app->poller = nfc_poller_alloc(app->nfc, NfcProtocolMfDesfire);
+    nfc_poller_start(app->poller, metroflip_scene_myki_poller_callback, app);
+
+    metroflip_app_blink_start(app);
+}
+
+bool metroflip_scene_myki_on_event(void* context, SceneManagerEvent event) {
+    Metroflip* app = context;
+    bool consumed = false;
+
+    if(event.type == SceneManagerEventTypeCustom) {
+        if(event.event == MetroflipCustomEventCardDetected) {
+            Popup* popup = app->popup;
+            popup_set_header(popup, "DON'T\nMOVE", 68, 30, AlignLeft, AlignTop);
+            consumed = true;
+        } else if(event.event == MetroflipCustomEventCardLost) {
+            Popup* popup = app->popup;
+            popup_set_header(popup, "Card \n lost", 68, 30, AlignLeft, AlignTop);
+            consumed = true;
+        } else if(event.event == MetroflipCustomEventWrongCard) {
+            Popup* popup = app->popup;
+            popup_set_header(popup, "WRONG \n CARD", 68, 30, AlignLeft, AlignTop);
+            consumed = true;
+        } else if(event.event == MetroflipCustomEventPollerFail) {
+            Popup* popup = app->popup;
+            popup_set_header(popup, "Failed", 68, 30, AlignLeft, AlignTop);
+            consumed = true;
+        }
+    } else if(event.type == SceneManagerEventTypeBack) {
+        scene_manager_search_and_switch_to_previous_scene(app->scene_manager, MetroflipSceneStart);
+        consumed = true;
+    }
+
+    return consumed;
+}
+
+void metroflip_scene_myki_on_exit(void* context) {
+    Metroflip* app = context;
+    widget_reset(app->widget);
+    metroflip_app_blink_stop(app);
+    if(app->poller) {
+        nfc_poller_stop(app->poller);
+        nfc_poller_free(app->poller);
+    }
+}

File diff suppressed because it is too large
+ 907 - 158
metroflip/scenes/metroflip_scene_navigo.c


+ 309 - 0
metroflip/scenes/metroflip_scene_opal.c

@@ -0,0 +1,309 @@
+/*
+ * opal.c - Parser for Opal card (Sydney, Australia).
+ *
+ * Copyright 2023 Michael Farrell <micolous+git@gmail.com>
+ *
+ * This will only read "standard" MIFARE DESFire-based Opal cards. Free travel
+ * cards (including School Opal cards, veteran, vision-impaired persons and
+ * TfNSW employees' cards) and single-trip tickets are MIFARE Ultralight C
+ * cards and not supported.
+ *
+ * Reference: https://github.com/metrodroid/metrodroid/wiki/Opal
+ *
+ * Note: The card values are all little-endian (like Flipper), but the above
+ * reference was originally written based on Java APIs, which are big-endian.
+ * This implementation presumes a little-endian system.
+ *
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#include "../metroflip_i.h"
+#include <flipper_application.h>
+
+#include <lib/nfc/protocols/mf_desfire/mf_desfire.h>
+#include <lib/nfc/protocols/mf_desfire/mf_desfire_poller.h>
+
+#include <applications/services/locale/locale.h>
+#include <datetime.h>
+
+#define TAG "Metroflip:Scene:Opal"
+
+static const MfDesfireApplicationId opal_app_id = {.data = {0x31, 0x45, 0x53}};
+
+static const MfDesfireFileId opal_file_id = 0x07;
+
+static const char* opal_modes[5] =
+    {"Rail / Metro", "Ferry / Light Rail", "Bus", "Unknown mode", "Manly Ferry"};
+
+static const char* opal_usages[14] = {
+    "New / Unused",
+    "Tap on: new journey",
+    "Tap on: transfer from same mode",
+    "Tap on: transfer from other mode",
+    NULL, // Manly Ferry: new journey
+    NULL, // Manly Ferry: transfer from ferry
+    NULL, // Manly Ferry: transfer from other
+    "Tap off: distance fare",
+    "Tap off: flat fare",
+    "Automated tap off: failed to tap off",
+    "Tap off: end of trip without start",
+    "Tap off: reversal",
+    "Tap on: rejected",
+    "Unknown usage",
+};
+
+// Opal file 0x7 structure. Assumes a little-endian CPU.
+typedef struct FURI_PACKED {
+    uint32_t serial         : 32;
+    uint8_t check_digit     : 4;
+    bool blocked            : 1;
+    uint16_t txn_number     : 16;
+    int32_t balance         : 21;
+    uint16_t days           : 15;
+    uint16_t minutes        : 11;
+    uint8_t mode            : 3;
+    uint16_t usage          : 4;
+    bool auto_topup         : 1;
+    uint8_t weekly_journeys : 4;
+    uint16_t checksum       : 16;
+} OpalFile;
+
+static_assert(sizeof(OpalFile) == 16, "OpalFile");
+
+// Converts an Opal timestamp to DateTime.
+//
+// Opal measures days since 1980-01-01 and minutes since midnight, and presumes
+// all days are 1440 minutes.
+static void opal_days_minutes_to_datetime(uint16_t days, uint16_t minutes, DateTime* out) {
+    out->year = 1980;
+    out->month = 1;
+    // 1980-01-01 is a Tuesday
+    out->weekday = ((days + 1) % 7) + 1;
+    out->hour = minutes / 60;
+    out->minute = minutes % 60;
+    out->second = 0;
+
+    // What year is it?
+    for(;;) {
+        const uint16_t num_days_in_year = datetime_get_days_per_year(out->year);
+        if(days < num_days_in_year) break;
+        days -= num_days_in_year;
+        out->year++;
+    }
+
+    // 1-index the day of the year
+    days++;
+
+    for(;;) {
+        // What month is it?
+        const bool is_leap = datetime_is_leap_year(out->year);
+        const uint8_t num_days_in_month = datetime_get_days_per_month(is_leap, out->month);
+        if(days <= num_days_in_month) break;
+        days -= num_days_in_month;
+        out->month++;
+    }
+
+    out->day = days;
+}
+
+static bool opal_parse(const NfcDevice* device, FuriString* parsed_data) {
+    furi_assert(device);
+    furi_assert(parsed_data);
+
+    const MfDesfireData* data = nfc_device_get_data(device, NfcProtocolMfDesfire);
+
+    bool parsed = false;
+
+    do {
+        const MfDesfireApplication* app = mf_desfire_get_application(data, &opal_app_id);
+        if(app == NULL) break;
+
+        const MfDesfireFileSettings* file_settings =
+            mf_desfire_get_file_settings(app, &opal_file_id);
+        if(file_settings == NULL || file_settings->type != MfDesfireFileTypeStandard ||
+           file_settings->data.size != sizeof(OpalFile))
+            break;
+
+        const MfDesfireFileData* file_data = mf_desfire_get_file_data(app, &opal_file_id);
+        if(file_data == NULL) break;
+
+        const OpalFile* opal_file = simple_array_cget_data(file_data->data);
+
+        const uint8_t serial2 = opal_file->serial / 10000000;
+        const uint16_t serial3 = (opal_file->serial / 1000) % 10000;
+        const uint16_t serial4 = (opal_file->serial % 1000);
+
+        if(opal_file->check_digit > 9) break;
+
+        // Negative balance. Make this a positive value again and record the
+        // sign separately, because then we can handle balances of -99..-1
+        // cents, as the "dollars" division below would result in a positive
+        // zero value.
+        const bool is_negative_balance = (opal_file->balance < 0);
+        const char* sign = is_negative_balance ? "-" : "";
+        const int32_t balance = is_negative_balance ? labs(opal_file->balance) : //-V1081
+                                                      opal_file->balance;
+        const uint8_t balance_cents = balance % 100;
+        const int32_t balance_dollars = balance / 100;
+
+        DateTime timestamp;
+        opal_days_minutes_to_datetime(opal_file->days, opal_file->minutes, &timestamp);
+
+        // Usages 4..6 associated with the Manly Ferry, which correspond to
+        // usages 1..3 for other modes.
+        const bool is_manly_ferry = (opal_file->usage >= 4) && (opal_file->usage <= 6);
+
+        // 3..7 are "reserved", but we use 4 to indicate the Manly Ferry.
+        const uint8_t mode = is_manly_ferry ? 4 : opal_file->mode;
+        const uint8_t usage = is_manly_ferry ? opal_file->usage - 3 : opal_file->usage;
+
+        const char* mode_str = opal_modes[mode > 4 ? 3 : mode];
+        const char* usage_str = opal_usages[usage > 12 ? 13 : usage];
+
+        furi_string_printf(
+            parsed_data,
+            "\e#Opal: $%s%ld.%02hu\nNo.: 3085 22%02hhu %04hu %03hu%01hhu\n%s, %s\n",
+            sign,
+            balance_dollars,
+            balance_cents,
+            serial2,
+            serial3,
+            serial4,
+            opal_file->check_digit,
+            mode_str,
+            usage_str);
+
+        FuriString* timestamp_str = furi_string_alloc();
+
+        locale_format_date(timestamp_str, &timestamp, locale_get_date_format(), "-");
+        furi_string_cat(parsed_data, timestamp_str);
+        furi_string_cat(parsed_data, " at ");
+
+        locale_format_time(timestamp_str, &timestamp, locale_get_time_format(), false);
+        furi_string_cat(parsed_data, timestamp_str);
+
+        furi_string_free(timestamp_str);
+
+        furi_string_cat_printf(
+            parsed_data,
+            "\nWeekly journeys: %hhu, Txn #%hu\n",
+            opal_file->weekly_journeys,
+            opal_file->txn_number);
+
+        if(opal_file->auto_topup) {
+            furi_string_cat_str(parsed_data, "Auto-topup enabled\n");
+        }
+
+        if(opal_file->blocked) {
+            furi_string_cat_str(parsed_data, "Card blocked\n");
+        }
+
+        parsed = true;
+    } while(false);
+
+    return parsed;
+}
+
+static NfcCommand metroflip_scene_opal_poller_callback(NfcGenericEvent event, void* context) {
+    furi_assert(event.protocol == NfcProtocolMfDesfire);
+
+    Metroflip* app = context;
+    NfcCommand command = NfcCommandContinue;
+
+    FuriString* parsed_data = furi_string_alloc();
+    Widget* widget = app->widget;
+    furi_string_reset(app->text_box_store);
+    const MfDesfirePollerEvent* mf_desfire_event = event.event_data;
+    if(mf_desfire_event->type == MfDesfirePollerEventTypeReadSuccess) {
+        nfc_device_set_data(
+            app->nfc_device, NfcProtocolMfDesfire, nfc_poller_get_data(app->poller));
+        if(!opal_parse(app->nfc_device, parsed_data)) {
+            furi_string_reset(app->text_box_store);
+            FURI_LOG_I(TAG, "Unknown card type");
+            furi_string_printf(parsed_data, "\e#Unknown card\n");
+        }
+        widget_add_text_scroll_element(widget, 0, 0, 128, 64, furi_string_get_cstr(parsed_data));
+
+        widget_add_button_element(
+            widget, GuiButtonTypeRight, "Exit", metroflip_exit_widget_callback, app);
+
+        furi_string_free(parsed_data);
+        view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewWidget);
+        metroflip_app_blink_stop(app);
+        command = NfcCommandStop;
+    } else if(mf_desfire_event->type == MfDesfirePollerEventTypeReadFailed) {
+        view_dispatcher_send_custom_event(app->view_dispatcher, MetroflipCustomEventPollerSuccess);
+        command = NfcCommandContinue;
+    }
+
+    return command;
+}
+
+void metroflip_scene_opal_on_enter(void* context) {
+    Metroflip* app = context;
+    dolphin_deed(DolphinDeedNfcRead);
+
+    // Setup view
+    Popup* popup = app->popup;
+    popup_set_header(popup, "Apply\n card to\nthe back", 68, 30, AlignLeft, AlignTop);
+    popup_set_icon(popup, 0, 3, &I_RFIDDolphinReceive_97x61);
+
+    // Start worker
+    view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewPopup);
+    nfc_scanner_alloc(app->nfc);
+    app->poller = nfc_poller_alloc(app->nfc, NfcProtocolMfDesfire);
+    nfc_poller_start(app->poller, metroflip_scene_opal_poller_callback, app);
+
+    metroflip_app_blink_start(app);
+}
+
+bool metroflip_scene_opal_on_event(void* context, SceneManagerEvent event) {
+    Metroflip* app = context;
+    bool consumed = false;
+
+    if(event.type == SceneManagerEventTypeCustom) {
+        if(event.event == MetroflipCustomEventCardDetected) {
+            Popup* popup = app->popup;
+            popup_set_header(popup, "DON'T\nMOVE", 68, 30, AlignLeft, AlignTop);
+            consumed = true;
+        } else if(event.event == MetroflipCustomEventCardLost) {
+            Popup* popup = app->popup;
+            popup_set_header(popup, "Card \n lost", 68, 30, AlignLeft, AlignTop);
+            consumed = true;
+        } else if(event.event == MetroflipCustomEventWrongCard) {
+            Popup* popup = app->popup;
+            popup_set_header(popup, "WRONG \n CARD", 68, 30, AlignLeft, AlignTop);
+            consumed = true;
+        } else if(event.event == MetroflipCustomEventPollerFail) {
+            Popup* popup = app->popup;
+            popup_set_header(popup, "Failed", 68, 30, AlignLeft, AlignTop);
+            consumed = true;
+        }
+    } else if(event.type == SceneManagerEventTypeBack) {
+        scene_manager_search_and_switch_to_previous_scene(app->scene_manager, MetroflipSceneStart);
+        consumed = true;
+    }
+
+    return consumed;
+}
+
+void metroflip_scene_opal_on_exit(void* context) {
+    Metroflip* app = context;
+    widget_reset(app->widget);
+    metroflip_app_blink_stop(app);
+    if(app->poller) {
+        nfc_poller_stop(app->poller);
+        nfc_poller_free(app->poller);
+    }
+}

+ 1 - 10
metroflip/scenes/metroflip_scene_ravkav.c

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

+ 1 - 10
metroflip/scenes/metroflip_scene_read_success.c

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

+ 15 - 0
metroflip/scenes/metroflip_scene_start.c

@@ -24,6 +24,21 @@ void metroflip_scene_start_on_enter(void* context) {
         metroflip_scene_start_submenu_callback,
         metroflip_scene_start_submenu_callback,
         app);
         app);
 
 
+    submenu_add_item(
+        submenu, "Clipper", MetroflipSceneClipper, metroflip_scene_start_submenu_callback, app);
+
+    submenu_add_item(
+        submenu, "myki", MetroflipSceneMyki, metroflip_scene_start_submenu_callback, app);
+
+    submenu_add_item(
+        submenu, "Troika", MetroflipSceneTroika, metroflip_scene_start_submenu_callback, app);
+
+    submenu_add_item(
+        submenu, "Opal", MetroflipSceneOpal, metroflip_scene_start_submenu_callback, app);
+
+    submenu_add_item(
+        submenu, "ITSO", MetroflipSceneItso, metroflip_scene_start_submenu_callback, app);
+
     submenu_add_item(
     submenu_add_item(
         submenu,
         submenu,
         "Metromoney",
         "Metromoney",

+ 311 - 0
metroflip/scenes/metroflip_scene_troika.c

@@ -0,0 +1,311 @@
+#include <flipper_application.h>
+#include "../metroflip_i.h"
+
+#include <nfc/protocols/mf_classic/mf_classic_poller_sync.h>
+#include <nfc/protocols/mf_classic/mf_classic.h>
+#include <nfc/protocols/mf_classic/mf_classic_poller.h>
+#include "../api/mosgortrans/mosgortrans_util.h"
+
+#include <dolphin/dolphin.h>
+#include <bit_lib.h>
+#include <furi_hal.h>
+#include <nfc/nfc.h>
+#include <nfc/nfc_device.h>
+#include <nfc/nfc_listener.h>
+
+#define TAG "Metroflip:Scene:Troika"
+
+typedef struct {
+    uint64_t a;
+    uint64_t b;
+} MfClassicKeyPair;
+
+typedef struct {
+    const MfClassicKeyPair* keys;
+    uint32_t data_sector;
+} TroikaCardConfig;
+
+static const MfClassicKeyPair troika_1k_keys[] = {
+    {.a = 0xa0a1a2a3a4a5, .b = 0xfbf225dc5d58},
+    {.a = 0xa82607b01c0d, .b = 0x2910989b6880},
+    {.a = 0x2aa05ed1856f, .b = 0xeaac88e5dc99},
+    {.a = 0x2aa05ed1856f, .b = 0xeaac88e5dc99},
+    {.a = 0x73068f118c13, .b = 0x2b7f3253fac5},
+    {.a = 0xfbc2793d540b, .b = 0xd3a297dc2698},
+    {.a = 0x2aa05ed1856f, .b = 0xeaac88e5dc99},
+    {.a = 0xae3d65a3dad4, .b = 0x0f1c63013dba},
+    {.a = 0xa73f5dc1d333, .b = 0xe35173494a81},
+    {.a = 0x69a32f1c2f19, .b = 0x6b8bd9860763},
+    {.a = 0x9becdf3d9273, .b = 0xf8493407799d},
+    {.a = 0x08b386463229, .b = 0x5efbaecef46b},
+    {.a = 0xcd4c61c26e3d, .b = 0x31c7610de3b0},
+    {.a = 0xa82607b01c0d, .b = 0x2910989b6880},
+    {.a = 0x0e8f64340ba4, .b = 0x4acec1205d75},
+    {.a = 0x2aa05ed1856f, .b = 0xeaac88e5dc99},
+};
+
+static const MfClassicKeyPair troika_4k_keys[] = {
+    {.a = 0xEC29806D9738, .b = 0xFBF225DC5D58}, //1
+    {.a = 0xA0A1A2A3A4A5, .b = 0x7DE02A7F6025}, //2
+    {.a = 0x2AA05ED1856F, .b = 0xEAAC88E5DC99}, //3
+    {.a = 0x2AA05ED1856F, .b = 0xEAAC88E5DC99}, //4
+    {.a = 0x73068F118C13, .b = 0x2B7F3253FAC5}, //5
+    {.a = 0xFBC2793D540B, .b = 0xD3A297DC2698}, //6
+    {.a = 0x2AA05ED1856F, .b = 0xEAAC88E5DC99}, //7
+    {.a = 0xAE3D65A3DAD4, .b = 0x0F1C63013DBA}, //8
+    {.a = 0xA73F5DC1D333, .b = 0xE35173494A81}, //9
+    {.a = 0x69A32F1C2F19, .b = 0x6B8BD9860763}, //10
+    {.a = 0x9BECDF3D9273, .b = 0xF8493407799D}, //11
+    {.a = 0x08B386463229, .b = 0x5EFBAECEF46B}, //12
+    {.a = 0xCD4C61C26E3D, .b = 0x31C7610DE3B0}, //13
+    {.a = 0xA82607B01C0D, .b = 0x2910989B6880}, //14
+    {.a = 0x0E8F64340BA4, .b = 0x4ACEC1205D75}, //15
+    {.a = 0x2AA05ED1856F, .b = 0xEAAC88E5DC99}, //16
+    {.a = 0x6B02733BB6EC, .b = 0x7038CD25C408}, //17
+    {.a = 0x403D706BA880, .b = 0xB39D19A280DF}, //18
+    {.a = 0xC11F4597EFB5, .b = 0x70D901648CB9}, //19
+    {.a = 0x0DB520C78C1C, .b = 0x73E5B9D9D3A4}, //20
+    {.a = 0x3EBCE0925B2F, .b = 0x372CC880F216}, //21
+    {.a = 0x16A27AF45407, .b = 0x9868925175BA}, //22
+    {.a = 0xABA208516740, .b = 0xCE26ECB95252}, //23
+    {.a = 0xCD64E567ABCD, .b = 0x8F79C4FD8A01}, //24
+    {.a = 0x764CD061F1E6, .b = 0xA74332F74994}, //25
+    {.a = 0x1CC219E9FEC1, .b = 0xB90DE525CEB6}, //26
+    {.a = 0x2FE3CB83EA43, .b = 0xFBA88F109B32}, //27
+    {.a = 0x07894FFEC1D6, .b = 0xEFCB0E689DB3}, //28
+    {.a = 0x04C297B91308, .b = 0xC8454C154CB5}, //29
+    {.a = 0x7A38E3511A38, .b = 0xAB16584C972A}, //30
+    {.a = 0x7545DF809202, .b = 0xECF751084A80}, //31
+    {.a = 0x5125974CD391, .b = 0xD3EAFB5DF46D}, //32
+    {.a = 0x7A86AA203788, .b = 0xE41242278CA2}, //33
+    {.a = 0xAFCEF64C9913, .b = 0x9DB96DCA4324}, //34
+    {.a = 0x04EAA462F70B, .b = 0xAC17B93E2FAE}, //35
+    {.a = 0xE734C210F27E, .b = 0x29BA8C3E9FDA}, //36
+    {.a = 0xD5524F591EED, .b = 0x5DAF42861B4D}, //37
+    {.a = 0xE4821A377B75, .b = 0xE8709E486465}, //38
+    {.a = 0x518DC6EEA089, .b = 0x97C64AC98CA4}, //39
+    {.a = 0xBB52F8CCE07F, .b = 0x6B6119752C70}, //40
+};
+
+static bool troika_get_card_config(TroikaCardConfig* config, MfClassicType type) {
+    bool success = true;
+
+    if(type == MfClassicType1k) {
+        config->data_sector = 11;
+        config->keys = troika_1k_keys;
+    } else if(type == MfClassicType4k) {
+        config->data_sector = 8; // Further testing needed
+        config->keys = troika_4k_keys;
+    } else {
+        success = false;
+    }
+
+    return success;
+}
+
+static bool troika_parse(FuriString* parsed_data, const MfClassicData* data) {
+    bool parsed = false;
+
+    do {
+        // Verify card type
+        TroikaCardConfig cfg = {};
+        if(!troika_get_card_config(&cfg, data->type)) break;
+
+        // Verify key
+        const MfClassicSectorTrailer* sec_tr =
+            mf_classic_get_sector_trailer_by_sector(data, cfg.data_sector);
+
+        const uint64_t key =
+            bit_lib_bytes_to_num_be(sec_tr->key_a.data, COUNT_OF(sec_tr->key_a.data));
+        if(key != cfg.keys[cfg.data_sector].a) break;
+
+        FuriString* metro_result = furi_string_alloc();
+        FuriString* ground_result = furi_string_alloc();
+        FuriString* tat_result = furi_string_alloc();
+
+        bool is_metro_data_present =
+            mosgortrans_parse_transport_block(&data->block[32], metro_result);
+        bool is_ground_data_present =
+            mosgortrans_parse_transport_block(&data->block[28], ground_result);
+        bool is_tat_data_present = mosgortrans_parse_transport_block(&data->block[16], tat_result);
+
+        furi_string_cat_printf(parsed_data, "\e#Troyka card\n");
+        if(is_metro_data_present && !furi_string_empty(metro_result)) {
+            render_section_header(parsed_data, "Metro", 22, 21);
+            furi_string_cat_printf(parsed_data, "%s\n", furi_string_get_cstr(metro_result));
+        }
+
+        if(is_ground_data_present && !furi_string_empty(ground_result)) {
+            render_section_header(parsed_data, "Ediny", 22, 22);
+            furi_string_cat_printf(parsed_data, "%s\n", furi_string_get_cstr(ground_result));
+        }
+
+        if(is_tat_data_present && !furi_string_empty(tat_result)) {
+            render_section_header(parsed_data, "TAT", 24, 23);
+            furi_string_cat_printf(parsed_data, "%s\n", furi_string_get_cstr(tat_result));
+        }
+
+        furi_string_free(tat_result);
+        furi_string_free(ground_result);
+        furi_string_free(metro_result);
+
+        parsed = is_metro_data_present || is_ground_data_present || is_tat_data_present;
+    } while(false);
+
+    return parsed;
+}
+
+bool checked = false;
+
+static NfcCommand metroflip_scene_troika_poller_callback(NfcGenericEvent event, void* context) {
+    furi_assert(context);
+    furi_assert(event.event_data);
+    furi_assert(event.protocol == NfcProtocolMfClassic);
+
+    NfcCommand command = NfcCommandContinue;
+    const MfClassicPollerEvent* mfc_event = event.event_data;
+    Metroflip* app = context;
+
+    if(mfc_event->type == MfClassicPollerEventTypeCardDetected) {
+        view_dispatcher_send_custom_event(app->view_dispatcher, MetroflipCustomEventCardDetected);
+        command = NfcCommandContinue;
+    } else if(mfc_event->type == MfClassicPollerEventTypeCardLost) {
+        view_dispatcher_send_custom_event(app->view_dispatcher, MetroflipCustomEventCardLost);
+        app->sec_num = 0;
+        command = NfcCommandStop;
+    } else if(mfc_event->type == MfClassicPollerEventTypeRequestMode) {
+        mfc_event->data->poller_mode.mode = MfClassicPollerModeRead;
+
+    } else if(mfc_event->type == MfClassicPollerEventTypeRequestReadSector) {
+        MfClassicKey key = {0};
+        MfClassicKeyType key_type = MfClassicKeyTypeA;
+        bit_lib_num_to_bytes_be(troika_1k_keys[app->sec_num].a, COUNT_OF(key.data), key.data);
+        if(!checked) {
+            mfc_event->data->read_sector_request_data.sector_num = app->sec_num;
+            mfc_event->data->read_sector_request_data.key = key;
+            mfc_event->data->read_sector_request_data.key_type = key_type;
+            mfc_event->data->read_sector_request_data.key_provided = true;
+            app->sec_num++;
+            checked = true;
+        }
+        nfc_device_set_data(
+            app->nfc_device, NfcProtocolMfClassic, nfc_poller_get_data(app->poller));
+        const MfClassicData* mfc_data = nfc_device_get_data(app->nfc_device, NfcProtocolMfClassic);
+        if(mfc_data->type == MfClassicType1k) {
+            bit_lib_num_to_bytes_be(troika_1k_keys[app->sec_num].a, COUNT_OF(key.data), key.data);
+
+            mfc_event->data->read_sector_request_data.sector_num = app->sec_num;
+            mfc_event->data->read_sector_request_data.key = key;
+            mfc_event->data->read_sector_request_data.key_type = key_type;
+            mfc_event->data->read_sector_request_data.key_provided = true;
+            if(app->sec_num == 16) {
+                mfc_event->data->read_sector_request_data.key_provided = false;
+                app->sec_num = 0;
+            }
+            app->sec_num++;
+        } else if(mfc_data->type == MfClassicType4k) {
+            bit_lib_num_to_bytes_be(troika_4k_keys[app->sec_num].a, COUNT_OF(key.data), key.data);
+
+            mfc_event->data->read_sector_request_data.sector_num = app->sec_num;
+            mfc_event->data->read_sector_request_data.key = key;
+            mfc_event->data->read_sector_request_data.key_type = key_type;
+            mfc_event->data->read_sector_request_data.key_provided = true;
+            if(app->sec_num == 40) {
+                mfc_event->data->read_sector_request_data.key_provided = false;
+                app->sec_num = 0;
+            }
+            app->sec_num++;
+        }
+    } else if(mfc_event->type == MfClassicPollerEventTypeSuccess) {
+        const MfClassicData* mfc_data = nfc_device_get_data(app->nfc_device, NfcProtocolMfClassic);
+        FuriString* parsed_data = furi_string_alloc();
+        Widget* widget = app->widget;
+        if(!troika_parse(parsed_data, mfc_data)) {
+            furi_string_reset(app->text_box_store);
+            FURI_LOG_I(TAG, "Unknown card type");
+            furi_string_printf(parsed_data, "\e#Unknown card\n");
+        }
+        widget_add_text_scroll_element(widget, 0, 0, 128, 64, furi_string_get_cstr(parsed_data));
+
+        widget_add_button_element(
+            widget, GuiButtonTypeRight, "Exit", metroflip_exit_widget_callback, app);
+
+        furi_string_free(parsed_data);
+        view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewWidget);
+        metroflip_app_blink_stop(app);
+        command = NfcCommandStop;
+    } else if(mfc_event->type == MfClassicPollerEventTypeFail) {
+        FURI_LOG_I(TAG, "fail");
+        command = NfcCommandStop;
+    }
+
+    return command;
+}
+
+void metroflip_scene_troika_on_enter(void* context) {
+    Metroflip* app = context;
+    dolphin_deed(DolphinDeedNfcRead);
+
+    app->sec_num = 0;
+
+    // Setup view
+    Popup* popup = app->popup;
+    popup_set_header(popup, "Apply\n card to\nthe back", 68, 30, AlignLeft, AlignTop);
+    popup_set_icon(popup, 0, 3, &I_RFIDDolphinReceive_97x61);
+
+    // Start worker
+    view_dispatcher_switch_to_view(app->view_dispatcher, MetroflipViewPopup);
+    nfc_scanner_alloc(app->nfc);
+    app->poller = nfc_poller_alloc(app->nfc, NfcProtocolMfClassic);
+    nfc_poller_start(app->poller, metroflip_scene_troika_poller_callback, app);
+
+    metroflip_app_blink_start(app);
+}
+
+bool metroflip_scene_troika_on_event(void* context, SceneManagerEvent event) {
+    Metroflip* app = context;
+    bool consumed = false;
+
+    if(event.type == SceneManagerEventTypeCustom) {
+        if(event.event == MetroflipCustomEventCardDetected) {
+            Popup* popup = app->popup;
+            popup_set_header(popup, "DON'T\nMOVE", 68, 30, AlignLeft, AlignTop);
+            consumed = true;
+        } else if(event.event == MetroflipCustomEventCardLost) {
+            Popup* popup = app->popup;
+            popup_set_header(popup, "Card \n lost", 68, 30, AlignLeft, AlignTop);
+            consumed = true;
+        } else if(event.event == MetroflipCustomEventWrongCard) {
+            Popup* popup = app->popup;
+            popup_set_header(popup, "WRONG \n CARD", 68, 30, AlignLeft, AlignTop);
+            consumed = true;
+        } else if(event.event == MetroflipCustomEventPollerFail) {
+            Popup* popup = app->popup;
+            popup_set_header(popup, "Failed", 68, 30, AlignLeft, AlignTop);
+            consumed = true;
+        } else if(event.event == MetroflipCustomEventPollerSuccess) {
+            scene_manager_next_scene(app->scene_manager, MetroflipSceneReadSuccess);
+            consumed = true;
+        }
+    } else if(event.type == SceneManagerEventTypeBack) {
+        scene_manager_search_and_switch_to_previous_scene(app->scene_manager, MetroflipSceneStart);
+        consumed = true;
+    }
+
+    return consumed;
+}
+
+void metroflip_scene_troika_on_exit(void* context) {
+    Metroflip* app = context;
+    widget_reset(app->widget);
+
+    if(app->poller) {
+        nfc_poller_stop(app->poller);
+        nfc_poller_free(app->poller);
+    }
+
+    // Clear view
+    popup_reset(app->popup);
+
+    metroflip_app_blink_stop(app);
+}

+ 706 - 279
metroflip/scenes/navigo.h

@@ -1,305 +1,732 @@
+#include <gui/gui.h>
+#include <gui/modules/widget_elements/widget_element.h>
+#include "../api/calypso/calypso_util.h"
+#include "../api/calypso/cards/navigo.h"
+#include <datetime.h>
+#include <stdbool.h>
+
 #ifndef METRO_LIST_H
 #ifndef METRO_LIST_H
 #define METRO_LIST_H
 #define METRO_LIST_H
 
 
-typedef struct {
-    const char* name;
-    const char* stations[14];
-} MetroLine;
-
 #ifndef NAVIGO_H
 #ifndef NAVIGO_H
 #define NAVIGO_H
 #define NAVIGO_H
 
 
+void metroflip_back_button_widget_callback(GuiButtonType result, InputType type, void* context);
+void metroflip_next_button_widget_callback(GuiButtonType result, InputType type, void* context);
+
 // Service Providers
 // Service Providers
-static const char* SERVICE_PROVIDERS[] = {[2] = "SNCF", [3] = "RATP"};
+static const char* SERVICE_PROVIDERS[] = {
+    [2] = "SNCF",
+    [3] = "RATP",
+    [115] = "CSO (VEOLIA)",
+    [116] = "R'Bus (VEOLIA)",
+    [156] = "Phebus",
+    [175] = "RATP (Veolia Transport Nanterre)"};
 
 
 // Transport Types
 // Transport Types
 static const char* TRANSPORT_LIST[] = {
 static const char* TRANSPORT_LIST[] = {
-    [1] = "Urban Bus",
-    [2] = "Interurban Bus",
+    [1] = "Bus Urbain",
+    [2] = "Bus Interurbain",
     [3] = "Metro",
     [3] = "Metro",
     [4] = "Tram",
     [4] = "Tram",
     [5] = "Train",
     [5] = "Train",
     [8] = "Parking"};
     [8] = "Parking"};
 
 
+typedef enum {
+    BUS_URBAIN = 1,
+    BUS_INTERURBAIN = 2,
+    METRO = 3,
+    TRAM = 4,
+    TRAIN = 5,
+    PARKING = 8
+} TRANSPORT_TYPE;
+
+typedef enum {
+    NAVIGO_EASY = 0,
+    NAVIGO_DECOUVERTE = 1,
+    NAVIGO_STANDARD = 2,
+    NAVIGO_INTEGRAL = 6,
+    IMAGINE_R = 14
+} CARD_STATUS;
+
 // Transition Types
 // Transition Types
 static const char* TRANSITION_LIST[] = {
 static const char* TRANSITION_LIST[] = {
-    [1] = "Entry",
-    [2] = "Exit",
-    [4] = "Inspection",
-    [6] = "Interchange (entry)",
-    [7] = "Interchange (exit)"};
+    [1] = "Validation en entree",
+    [2] = "Validation en sortie",
+    [4] = "Controle volant (a bord)",
+    [5] = "Validation de test",
+    [6] = "Validation en correspondance (entree)",
+    [7] = "Validation en correspondance (sortie)",
+    [9] = "Annulation de validation",
+    [10] = "Validation en entree",
+    [13] = "Distribution",
+    [15] = "Invalidation"};
 
 
 #endif // NAVIGO_H
 #endif // NAVIGO_H
 
 
-const MetroLine METRO_LIST[] = {
-    [0] =
-        {"Cite",
-         {"Saint-Michel",
-          "Odeon",
-          "Cluny - La Sorbonne",
-          "Maubert - Mutualite",
-          "Luxembourg",
-          "Chatelet",
-          "Les Halles",
-          "Les Halles",
-          "Louvre - Rivoli",
-          "Pont Neuf",
-          "Cite",
-          "Hotel de Ville"}},
-    [1] =
-        {"Rennes",
-         {"Cambronne",
-          "Sevres - Lecourbe",
-          "Segur",
-          "Saint-Francois-Xavier",
-          "Duroc",
-          "Vaneau",
-          "Sevres - Babylone",
-          "Rue du Bac",
-          "Rennes",
-          "Saint-Sulpice",
-          "Mabillon",
-          "Saint-Germain-des-Pres"}},
-    [2] =
-        {"Villette",
-         {"Porte de la Villette",
-          "Aubervilliers - Pantin - Quatre Chemins",
-          "Fort d'Aubervilliers",
-          "La Courneuve - 8 Mai 1945",
-          "Hoche",
-          "Eglise de Pantin",
-          "Bobigny - Pantin - Raymond Queneau",
-          "Bobigny - Pablo Picasso"}},
-    [3] =
-        {"Montparnasse",
-         {"Pernety",
-          "Plaisance",
-          "Gaite",
-          "Edgar Quinet",
-          "Vavin",
-          "Montparnasse - Bienvenue",
-          "Saint-Placide",
-          "Notre-Dame-des-Champs"}},
-    [4] =
-        {"Nation",
-         {"Robespierre",
-          "Porte de Montreuil",
-          "Maraichers",
-          "Buzenval",
-          "Rue des Boulets",
-          "Porte de Vincennes",
-          "Picpus",
-          "Nation",
-          "Avron",
-          "Alexandre Dumas"}},
-    [5] =
-        {"Saint-Lazare",
-         {"Malesherbes",
-          "Monceau",
-          "Villiers",
-          "Quatre-Septembre",
-          "Opera",
-          "Auber",
-          "Havre - Caumartin",
-          "Saint-Lazare",
-          "Saint-Lazare",
-          "Saint-Augustin",
-          "Europe",
-          "Liege"}},
-    [6] =
-        {"Auteuil",
-         {"Porte de Saint-Cloud",
-          "Porte d'Auteuil",
-          "Eglise d'Auteuil",
-          "Michel-Ange - Auteuil",
-          "Michel-Ange - Molitor",
-          "Chardon-Lagache",
-          "Mirabeau",
-          "Exelmans",
-          "Jasmin"}},
-    [7] =
-        {"Republique",
-         {"Rambuteau",
-          "Arts et Metiers",
-          "Jacques Bonsergent",
-          "Goncourt",
-          "Temple",
-          "Republique",
-          "Oberkampf",
-          "Parmentier",
-          "Filles du Calvaire",
-          "Saint-Sebastien - Froissart",
-          "Richard-Lenoir",
-          "Saint-Ambroise"}},
-    [8] =
-        {"Austerlitz",
-         {"Quai de la Gare",
-          "Chevaleret",
-          "Saint-Marcel",
-          "Gare d'Austerlitz",
-          "Gare de Lyon",
-          "Quai de la Rapee"}},
-    [9] =
-        {"Invalides",
-         {"Champs-Elysees - Clemenceau",
-          "Concorde",
-          "Madeleine",
-          "Bir-Hakeim",
-          "Ecole Militaire",
-          "La Tour-Maubourg",
-          "Invalides",
-          "Saint-Denis - Universite",
-          "Varenne",
-          "Assemblee nationale",
-          "Solferino"}},
-    [10] =
-        {"Sentier",
-         {"Tuileries",
-          "Palais Royal - Musee du Louvre",
-          "Pyramides",
-          "Bourse",
-          "Grands Boulevards",
-          "Richelieu - Drouot",
-          "Bonne Nouvelle",
-          "Strasbourg - Saint-Denis",
-          "Chateau d'Eau",
-          "Sentier",
-          "Reaumur - Sebastopol",
-          "Etienne Marcel"}},
-    [11] =
-        {"Ile Saint-Louis",
-         {"Faidherbe - Chaligny",
-          "Reuilly - Diderot",
-          "Montgallet",
-          "Censier - Daubenton",
-          "Place Monge",
-          "Cardinal Lemoine",
-          "Jussieu",
-          "Sully - Morland",
-          "Pont Marie",
-          "Saint-Paul",
-          "Bastille",
-          "Chemin Vert",
-          "Breguet - Sabin",
-          "Ledru-Rollin"}},
-    [12] =
-        {"Daumesnil",
-         {"Porte Doree",
-          "Porte de Charenton",
-          "Bercy",
-          "Dugommier",
-          "Michel Bizot",
-          "Daumesnil",
-          "Bel-Air"}},
-    [13] =
-        {"Italie",
-         {"Porte de Choisy",
-          "Porte d'Italie",
-          "Cite universitaire",
-          "Maison Blanche",
-          "Tolbiac",
-          "Nationale",
-          "Campo-Formio",
-          "Les Gobelins",
-          "Place d'Italie",
-          "Corvisart"}},
-    [14] =
-        {"Denfert",
-         {"Cour Saint-Emilion",
-          "Porte d'Orleans",
-          "Bibliotheque Francois Mitterrand",
-          "Mouton-Duvernet",
-          "Alesia",
-          "Olympiades",
-          "Glaciere",
-          "Saint-Jacques",
-          "Raspail",
-          "Denfert-Rochereau"}},
-    [15] =
-        {"Felix Faure",
-         {"Falguiere",
-          "Pasteur",
-          "Volontaires",
-          "Vaugirard",
-          "Convention",
-          "Porte de Versailles",
-          "Balard",
-          "Lourmel",
-          "Boucicaut",
-          "Felix Faure",
-          "Charles Michels",
-          "Javel - Andre Citroen"}},
+static const char* METRO_STATION_LIST[32][16] =
+    {[1] =
+         {[0] = "Cite",
+          [1] = "Saint-Michel",
+          [4] = "Odeon",
+          [5] = "Cluny - La Sorbonne",
+          [6] = "Maubert - Mutualite",
+          [7] = "Luxembourg",
+          [8] = "Châtelet",
+          [9] = "Les Halles",
+          [10] = "Les Halles",
+          [12] = "Louvre - Rivoli",
+          [13] = "Pont Neuf",
+          [14] = "Cite",
+          [15] = "Hotel de Ville"},
+     [2] =
+         {[0] = "Rennes",
+          [2] = "Cambronne",
+          [3] = "Sevres - Lecourbe",
+          [4] = "Segur",
+          [6] = "Saint-François-Xavier",
+          [7] = "Duroc",
+          [8] = "Vaneau",
+          [9] = "Sevres - Babylone",
+          [10] = "Rue du Bac",
+          [11] = "Rennes",
+          [12] = "Saint-Sulpice",
+          [14] = "Mabillon",
+          [15] = "Saint-Germain-des-Pres"},
+     [3] =
+         {[0] = "Villette",
+          [4] = "Porte de la Villette",
+          [5] = "Aubervilliers - Pantin - Quatre Chemins",
+          [6] = "Fort d'Aubervilliers",
+          [7] = "La Courneuve - 8 Mai 1945",
+          [9] = "Hoche",
+          [10] = "Eglise de Pantin",
+          [11] = "Bobigny - Pantin - Raymond Queneau",
+          [12] = "Bobigny - Pablo Picasso"},
+     [4] =
+         {[0] = "Montparnasse",
+          [2] = "Pernety",
+          [3] = "Plaisance",
+          [4] = "Gaite",
+          [6] = "Edgar Quinet",
+          [7] = "Vavin",
+          [8] = "Montparnasse - Bienvenue",
+          [12] = "Saint-Placide",
+          [14] = "Notre-Dame-des-Champs"},
+     [5] =
+         {[0] = "Nation",
+          [2] = "Robespierre",
+          [3] = "Porte de Montreuil",
+          [4] = "Maraichers",
+          [5] = "Buzenval",
+          [6] = "Rue des Boulets",
+          [7] = "Porte de Vincennes",
+          [9] = "Picpus",
+          [10] = "Nation",
+          [12] = "Avron",
+          [13] = "Alexandre Dumas"},
+     [6] =
+         {[0] = "Saint-Lazare",
+          [1] = "Malesherbes",
+          [2] = "Monceau",
+          [3] = "Villiers",
+          [4] = "Quatre-Septembre",
+          [5] = "Opera",
+          [6] = "Auber",
+          [7] = "Havre - Caumartin",
+          [8] = "Saint-Lazare",
+          [9] = "Saint-Lazare",
+          [10] = "Saint-Augustin",
+          [12] = "Europe",
+          [13] = "Liege"},
+     [7] =
+         {[0] = "Auteuil",
+          [3] = "Porte de Saint-Cloud",
+          [7] = "Porte d'Auteuil",
+          [8] = "eglise d'Auteuil",
+          [9] = "Michel-Ange - Auteuil",
+          [10] = "Michel-Ange - Molitor",
+          [11] = "Chardon-Lagache",
+          [12] = "Mirabeau",
+          [14] = "Exelmans",
+          [15] = "Jasmin"},
+     [8] =
+         {[0] = "Republique",
+          [1] = "Rambuteau",
+          [3] = "Arts et Metiers",
+          [4] = "Jacques Bonsergent",
+          [5] = "Goncourt",
+          [6] = "Temple",
+          [7] = "Republique",
+          [10] = "Oberkampf",
+          [11] = "Parmentier",
+          [12] = "Filles du Calvaire",
+          [13] = "Saint-Sebastien - Froissart",
+          [14] = "Richard-Lenoir",
+          [15] = "Saint-Ambroise"},
+     [9] =
+         {[0] = "Austerlitz",
+          [1] = "Quai de la Gare",
+          [2] = "Chevaleret",
+          [4] = "Saint-Marcel",
+          [7] = "Gare d'Austerlitz",
+          [8] = "Gare de Lyon",
+          [10] = "Quai de la Rapee"},
+     [10] =
+         {[0] = "Invalides",
+          [1] = "Champs-elysees - Clemenceau",
+          [2] = "Concorde",
+          [3] = "Madeleine",
+          [4] = "Bir-Hakeim",
+          [7] = "ecole Militaire",
+          [8] = "La Tour-Maubourg",
+          [9] = "Invalides",
+          [11] = "Saint-Denis - Universite",
+          [12] = "Varenne",
+          [13] = "Assemblee nationale",
+          [14] = "Solferino"},
+     [11] =
+         {[0] = "Sentier",
+          [1] = "Tuileries",
+          [2] = "Palais Royal - Musee du Louvre",
+          [3] = "Pyramides",
+          [4] = "Bourse",
+          [6] = "Grands Boulevards",
+          [7] = "Richelieu - Drouot",
+          [8] = "Bonne Nouvelle",
+          [10] = "Strasbourg - Saint-Denis",
+          [11] = "Château d'Eau",
+          [13] = "Sentier",
+          [14] = "Reaumur - Sebastopol",
+          [15] = "etienne Marcel"},
+     [12] =
+         {[0] = "ile Saint-Louis",
+          [1] = "Faidherbe - Chaligny",
+          [2] = "Reuilly - Diderot",
+          [3] = "Montgallet",
+          [4] = "Censier - Daubenton",
+          [5] = "Place Monge",
+          [6] = "Cardinal Lemoine",
+          [7] = "Jussieu",
+          [8] = "Sully - Morland",
+          [9] = "Pont Marie",
+          [10] = "Saint-Paul",
+          [12] = "Bastille",
+          [13] = "Chemin Vert",
+          [14] = "Breguet - Sabin",
+          [15] = "Ledru-Rollin"},
+     [13] =
+         {[0] = "Daumesnil",
+          [1] = "Porte Doree",
+          [3] = "Porte de Charenton",
+          [7] = "Bercy",
+          [8] = "Dugommier",
+          [10] = "Michel Bizot",
+          [11] = "Daumesnil",
+          [12] = "Bel-Air"},
+     [14] =
+         {[0] = "Italie",
+          [2] = "Porte de Choisy",
+          [3] = "Porte d'Italie",
+          [4] = "Cite universitaire",
+          [9] = "Maison Blanche",
+          [10] = "Tolbiac",
+          [11] = "Nationale",
+          [12] = "Campo-Formio",
+          [13] = "Les Gobelins",
+          [14] = "Place d'Italie",
+          [15] = "Corvisart"},
+     [15] =
+         {[0] = "Denfert",
+          [1] = "Cour Saint-Emilion",
+          [2] = "Porte d'Orleans",
+          [3] = "Bibliotheque François Mitterrand",
+          [4] = "Mouton-Duvernet",
+          [5] = "Alesia",
+          [6] = "Olympiades",
+          [8] = "Glaciere",
+          [9] = "Saint-Jacques",
+          [10] = "Raspail",
+          [14] = "Denfert-Rochereau"},
+     [16] =
+         {[0] = "Felix Faure",
+          [1] = "Falguiere",
+          [2] = "Pasteur",
+          [3] = "Volontaires",
+          [4] = "Vaugirard",
+          [5] = "Convention",
+          [6] = "Porte de Versailles",
+          [9] = "Balard",
+          [10] = "Lourmel",
+          [11] = "Boucicaut",
+          [12] = "Felix Faure",
+          [13] = "Charles Michels",
+          [14] = "Javel - Andre Citroen"},
+     [17] =
+         {[0] = "Passy",
+          [2] = "Porte Dauphine",
+          [4] = "La Motte-Picquet - Grenelle",
+          [5] = "Commerce",
+          [6] = "Avenue emile Zola",
+          [7] = "Dupleix",
+          [8] = "Passy",
+          [9] = "Ranelagh",
+          [11] = "La Muette",
+          [13] = "Rue de la Pompe",
+          [14] = "Boissiere",
+          [15] = "Trocadero"},
+     [18] =
+         {[0] = "Etoile",
+          [1] = "Iena",
+          [3] = "Alma - Marceau",
+          [4] = "Miromesnil",
+          [5] = "Saint-Philippe du Roule",
+          [7] = "Franklin D. Roosevelt",
+          [8] = "George V",
+          [9] = "Kleber",
+          [10] = "Victor Hugo",
+          [11] = "Argentine",
+          [12] = "Charles de Gaulle - Itoile",
+          [14] = "Ternes",
+          [15] = "Courcelles"},
+     [19] =
+         {[0] = "Clichy - Saint Ouen",
+          [1] = "Mairie de Clichy",
+          [2] = "Gabriel Peri",
+          [3] = "Les Agnettes",
+          [4] = "Asnieres - Gennevilliers - Les Courtilles",
+          [9] = "La Chapelle)",
+          [10] = "Garibaldi",
+          [11] = "Mairie de Saint-Ouen",
+          [13] = "Carrefour Pleyel",
+          [14] = "Saint-Denis - Porte de Paris",
+          [15] = "Basilique de Saint-Denis"},
+     [20] =
+         {[0] = "Montmartre",
+          [1] = "Porte de Clignancourt",
+          [6] = "Porte de la Chapelle",
+          [7] = "Marx Dormoy",
+          [9] = "Marcadet - Poissonniers",
+          [10] = "Simplon",
+          [11] = "Jules Joffrin",
+          [12] = "Lamarck - Caulaincourt"},
+     [21] =
+         {[0] = "Lafayette",
+          [1] = "Chaussee d'Antin - La Fayette",
+          [2] = "Le Peletier",
+          [3] = "Cadet",
+          [4] = "Château Rouge",
+          [7] = "Barbes - Rochechouart",
+          [8] = "Gare du Nord",
+          [9] = "Gare de l'Est",
+          [10] = "Poissonniere",
+          [11] = "Château-Landon"},
+     [22] =
+         {[0] = "Buttes Chaumont",
+          [1] = "Porte de Pantin",
+          [2] = "Ourcq",
+          [4] = "Corentin Cariou",
+          [6] = "Crimee",
+          [8] = "Riquet",
+          [9] = "La Chapelle",
+          [10] = "Louis Blanc",
+          [11] = "Stalingrad",
+          [12] = "Jaures",
+          [13] = "Laumiere",
+          [14] = "Bolivar",
+          [15] = "Colonel Fabien"},
+     [23] =
+         {[0] = "Belleville",
+          [2] = "Porte des Lilas",
+          [3] = "Mairie des Lilas",
+          [4] = "Porte de Bagnolet",
+          [5] = "Gallieni",
+          [8] = "Place des Fetes",
+          [9] = "Botzaris",
+          [10] = "Danube",
+          [11] = "Pre Saint-Gervais",
+          [13] = "Buttes Chaumont",
+          [14] = "Jourdain",
+          [15] = "Telegraphe"},
+     [24] =
+         {[0] = "Pere Lachaise",
+          [1] = "Voltaire",
+          [2] = "Charonne",
+          [4] = "Pere Lachaise",
+          [5] = "Menilmontant",
+          [6] = "Rue Saint-Maur",
+          [7] = "Philippe Auguste",
+          [8] = "Saint-Fargeau",
+          [9] = "Pelleport",
+          [10] = "Gambetta",
+          [12] = "Belleville",
+          [13] = "Couronnes",
+          [14] = "Pyrenees"},
+     [25] =
+         {[0] = "Charenton",
+          [2] = "Croix de Chavaux",
+          [3] = "Mairie de Montreuil",
+          [4] = "Maisons-Alfort - Les Juilliottes",
+          [5] = "Creteil - L'echat",
+          [6] = "Creteil - Universite",
+          [7] = "Creteil - Prefecture",
+          [8] = "Saint-Mande",
+          [10] = "Berault",
+          [11] = "Château de Vincennes",
+          [12] = "Liberte",
+          [13] = "Charenton - ecoles",
+          [14] = "ecole veterinaire de Maisons-Alfort",
+          [15] = "Maisons-Alfort - Stade"},
+     [26] =
+         {[0] = "Ivry - Villejuif",
+          [3] = "Porte d'Ivry",
+          [4] = "Pierre et Marie Curie",
+          [5] = "Mairie d'Ivry",
+          [6] = "Le Kremlin-Bicetre",
+          [7] = "Villejuif - Leo Lagrange",
+          [8] = "Villejuif - Paul Vaillant-Couturier",
+          [9] = "Villejuif - Louis Aragon"},
+     [27] =
+         {[0] = "Vanves",
+          [2] = "Porte de Vanves",
+          [7] = "Malakoff - Plateau de Vanves",
+          [8] = "Malakoff - Rue etienne Dolet",
+          [9] = "Châtillon - Montrouge"},
+     [28] =
+         {[0] = "Issy",
+          [2] = "Corentin Celton",
+          [3] = "Mairie d'Issy",
+          [8] = "Marcel Sembat",
+          [9] = "Billancourt",
+          [10] = "Pont de Sevres"},
+     [29] =
+         {[0] = "Levallois",
+          [4] = "Boulogne - Jean Jaures",
+          [5] = "Boulogne - Pont de Saint-Cloud",
+          [8] = "Les Sablons",
+          [9] = "Pont de Neuilly",
+          [10] = "Esplanade de la Defense",
+          [11] = "La Defense",
+          [12] = "Porte de Champerret",
+          [13] = "Louise Michel",
+          [14] = "Anatole France",
+          [15] = "Pont de Levallois - Becon"},
+     [30] =
+         {[0] = "Pereire",
+          [1] = "Porte Maillot",
+          [4] = "Wagram",
+          [5] = "Pereire",
+          [8] = "Brochant",
+          [9] = "Porte de Clichy",
+          [12] = "Guy Moquet",
+          [13] = "Porte de Saint-Ouen"},
+     [31] = {
+         [0] = "Pigalle",
+         [2] = "Funiculaire de Montmartre (station inferieure)",
+         [3] = "Funiculaire de Montmartre (station superieure)",
+         [4] = "Anvers",
+         [5] = "Abbesses",
+         [6] = "Pigalle",
+         [7] = "Blanche",
+         [8] = "Trinite - d'Estienne d'Orves",
+         [9] = "Notre-Dame-de-Lorette",
+         [10] = "Saint-Georges",
+         [12] = "Rome",
+         [13] = "Place de Clichy",
+         [14] = "La Fourche"}};
+
+static const char* TRAIN_LINES_LIST[77] = {
+    [1] = "RER B",         [3] = "RER B",         [6] = "RER A",         [14] = "RER B",
+    [15] = "RER B",        [16] = "RER A",        [17] = "RER A",        [18] = "RER B",
+    [20] = "Transilien P", [21] = "Transilien P", [22] = "T4",           [23] = "Transilien P",
+    [26] = "RER A",        [28] = "RER B",        [30] = "Transilien L", [31] = "Transilien L",
+    [32] = "Transilien J", [33] = "RER A",        [35] = "Transilien J", [40] = "RER D",
+    [41] = "RER C",        [42] = "RER C",        [43] = "Transilien R", [44] = "Transilien R",
+    [45] = "RER D",        [50] = "Transilien H", [51] = "Transilien K", [52] = "RER D",
+    [53] = "Transilien H", [54] = "Transilien J", [55] = "RER C",        [56] = "Transilien H",
+    [57] = "Transilien H", [60] = "Transilien N", [61] = "Transilien N", [63] = "RER C",
+    [64] = "RER C",        [65] = "Transilien V", [70] = "RER B",        [72] = "Transilien J",
+    [73] = "Transilien J", [75] = "RER C",        [76] = "RER C"};
+
+static const char* TRAIN_STATION_LIST[77][19] = {
+    [1] = {[0] = "Châtelet-Les Halles", [1] = "Châtelet-Les Halles", [7] = "Luxembourg"},
+    [3] = {[0] = "Saint-Michel Notre-Dame"},
+    [6] = {[0] = "Auber", [6] = "Auber"},
+    [14] = {[4] = "Cite Universitaire"},
+    [15] = {[12] = "Port Royal"},
     [16] =
     [16] =
-        {"Passy",
-         {"Porte Dauphine",
-          "La Motte-Picquet - Grenelle",
-          "Commerce",
-          "Avenue Emile Zola",
-          "Dupleix",
-          "Passy",
-          "Ranelagh",
-          "La Muette",
-          "Rue de la Pompe",
-          "Boissiere",
-          "Trocadero"}},
+        {[1] = "Nation",
+         [2] = "Fontenay-sous-Bois | Vincennes",
+         [3] = "Joinville-le-Pont | Nogent-sur-Marne",
+         [4] = "Saint-Maur Creteil",
+         [5] = "Le Parc de Saint-Maur",
+         [6] = "Champigny",
+         [7] = "La Varenne-Chennevieres",
+         [8] = "Boissy-Saint-Leger | Sucy Bonneuil"},
     [17] =
     [17] =
-        {"Etoile",
-         {"Iena",
-          "Alma - Marceau",
-          "Miromesnil",
-          "Saint-Philippe du Roule",
-          "Franklin D. Roosevelt",
-          "George V",
-          "Kleber",
-          "Victor Hugo",
-          "Argentine",
-          "Charles de Gaulle - Etoile",
-          "Ternes",
-          "Courcelles"}},
+        {[1] = "Charles de Gaulle-Etoile",
+         [4] = "La Defense (Grande Arche)",
+         [5] = "Nanterre-Ville",
+         [6] = "Rueil-Malmaison",
+         [8] = "Chatou-Croissy",
+         [9] = "Le Vesinet-Centre | Le Vesinet-Le Pecq | Saint-Germain-en-Laye"},
     [18] =
     [18] =
-        {"Clichy - Saint Ouen",
-         {"Mairie de Clichy",
-          "Gabriel Peri",
-          "Les Agnettes",
-          "Asnieres - Gennevilliers - Les Courtilles",
-          "La Chapelle",
-          "Garibaldi",
-          "Mairie de Saint-Ouen",
-          "Carrefour Pleyel",
-          "Saint-Denis - Porte de Paris",
-          "Basilique de Saint-Denis"}},
-    [19] =
-        {"Montmartre",
-         {"Porte de Clignancourt",
-          "Porte de la Chapelle",
-          "Marx Dormoy",
-          "Marcadet - Poissonniers",
-          "Simplon",
-          "Jules Joffrin",
-          "Lamarck - Caulaincourt"}},
+        {[0] = "Denfert-Rochereau",
+         [1] = "Gentilly",
+         [2] = "Arcueil-Cachan | Laplace",
+         [3] = "Bagneux | Bourg-la-Reine",
+         [4] = "La Croix-de-Berny | Parc de Sceaux",
+         [5] = "Antony | Fontaine-Michalon | Les Baconnets",
+         [6] = "Massy-Palaiseau | Massy-Verrieres",
+         [7] = "Palaiseau Villebon | Palaiseau",
+         [8] = "Lozere",
+         [9] = "Le Guichet | Orsay-Ville",
+         [10] =
+             "Bures-sur-Yvette | Courcelle-sur-Yvette | Gif-sur-Yvette | La Hacquiniere | Saint-Remy-les-Chevreuse"},
     [20] =
     [20] =
-        {"Lafayette",
-         {"Chaussee d'Antin - La Fayette",
-          "Le Peletier",
-          "Cadet",
-          "Chateau Rouge",
-          "Barbes - Rochechouart",
-          "Gare du Nord",
-          "Gare de l'Est",
-          "Poissonniere",
-          "Chateau-Landon"}},
-    [21] = {
-        "Buttes Chaumont",
-        {"Porte de Pantin",
-         "Ourcq",
-         "Corentin Cariou",
-         "Crimee",
-         "Riquet",
-         "La Chapelle",
-         "Belleville",
-         "Botzaris",
-         "Pelleport",
-         "Place des Fetes",
-         "Cimetiere du Pere Lachaise"}}};
+        {[1] = "Gare de l'Est",
+         [4] = "Pantin",
+         [5] = "Noisy-le-Sec",
+         [6] = "Bondy",
+         [7] = "Gagny | Le Raincy Villemomble Montfermeil",
+         [9] = "Chelles Gournay | Le Chenay Gagny",
+         [10] = "Vaires Torcy",
+         [11] = "Lagny-Thorigny",
+         [13] = "Esbly",
+         [14] = "Meaux",
+         [15] = "Changis-Saint-Jean | Isles-Armentieres Congis | Lizy-sur-Ourcq | Trilport",
+         [16] = "Crouy-sur-Ourcq | La Ferte-sous-Jouarre | Nanteuil Saacy"},
+    [21] =
+        {[5] = "Rosny-Bois-Perrier | Rosny-sous-Bois | Val de Fontenay",
+         [6] = "Nogent Le-Perreux",
+         [7] = "Les Boullereaux Champigny",
+         [8] = "Villiers-sur-Marne Plessis-Trevise",
+         [9] = "Les Yvris Noisy-le-Grand",
+         [10] = "Emerainville Pontault-Combault | Roissy-en-Brie",
+         [11] = "Ozoir-la-Ferriere",
+         [12] = "Gretz-Armainvilliers | Tournan",
+         [15] =
+             "Courquetaine | Faremoutiers Pommeuse | Guerard La-Celle-sur-Morin | Liverdy en Brie | Marles-en-Brie | Mormant | Mortcerf | Mouroux | Ozouer le voulgis | Verneuil-l'Etang | Villepatour - Presles | Yebles - Guignes | Yebles",
+         [16] =
+             "Chailly Boissy-le-Châtel | Chauffry | Coulommiers | Jouy-sur-Morin Le-Marais | Nangis | Saint-Remy-la-Vanne | Saint-Simeon",
+         [17] =
+             "Champbenoist-Poigny | La Ferte-Gaucher | Longueville | Provins | Sainte-Colombe-Septveilles",
+         [18] = "Flamboin | Meilleray | Villiers St Georges"},
+    [22] =
+        {[7] =
+             "Allee de la Tour-Rendez-Vous | La Remise-a-Jorelle | Les Coquetiers | Les Pavillons-sous-Bois",
+         [8] = "Gargan",
+         [9] = "Freinville Sevran | L'Abbaye"},
+    [23] =
+        {[13] = "Couilly Saint-Germain Quincy | Les Champs-Forts | Montry Conde",
+         [14] = "Crecy-en-Brie La Chapelle | Villiers-Montbarbin"},
+    [26] =
+        {[5] = "Val de Fontenay",
+         [6] = "Bry-sur-Marne | Neuilly-Plaisance",
+         [7] = "Noisy-le-Grand (Mont d'Est)",
+         [8] = "Noisy-Champs",
+         [10] = "Lognes | Noisiel | Torcy",
+         [11] = "Bussy-Saint-Georges",
+         [12] = "Val d'europe",
+         [13] = "Marne-la-Vallee Chessy"},
+    [28] = {[4] = "Fontenay-aux-Roses | Robinson | Sceaux"},
+    [30] =
+        {[1] = "Gare Saint-Lazare",
+         [3] = "Pont Cardinet",
+         [4] =
+             "Asnieres | Becon-les-Bruyeres | Clichy Levallois | Courbevoie | La Defense (Grande Arche)",
+         [5] = "Puteaux | Suresnes Mont-Valerien",
+         [7] = "Garches Marne-la-Coquette | Le Val d'Or | Saint-Cloud",
+         [8] = "Vaucresson",
+         [9] = "Bougival | La Celle-Saint-Cloud | Louveciennes | Marly-le-Roi",
+         [10] = "L'Etang-la-Ville | Saint-Nom-la-Breteche Foret de Marly"},
+    [31] =
+        {[7] = "Chaville-Rive Droite | Sevres Ville-d'Avray | Viroflay-Rive Droite",
+         [8] = "Montreuil | Versailles-Rive Droite"},
+    [32] =
+        {[5] = "La Garenne-Colombes | Les Vallees | Nanterre-Universite",
+         [7] = "Houilles Carrieres-sur-Seine | Sartrouville",
+         [9] = "Maisons-Laffitte",
+         [10] = "Poissy",
+         [11] = "Villennes-sur-Seine",
+         [12] = "Les Clairieres de Verneuil | Vernouillet Verneuil",
+         [13] = "Aubergenville-Elisabethville | Les Mureaux",
+         [14] = "Epone Mezieres",
+         [16] = "Bonnieres | Mantes-Station | Mantes-la-Jolie | Port-Villez | Rosny-sur-Seine"},
+    [33] =
+        {[10] = "Acheres-Grand-Cormier | Acheres-Ville",
+         [11] = "Cergy-Prefecture | Neuville-Universite",
+         [12] = "Cergy-Saint-Christophe | Cergy-le-Haut"},
+    [35] =
+        {[4] = "Bois-Colombes",
+         [5] = "Colombes | Le Stade",
+         [6] = "Argenteuil | Argenteuil",
+         [8] = "Cormeilles-en-Parisis | Val d'Argenteuil | Val d'Argenteuil",
+         [9] = "Herblay | La Frette Montigny",
+         [10] = "Conflans-Fin d'Oise | Conflans-Sainte-Honorine",
+         [11] = "Andresy | Chanteloup-les-Vignes | Maurecourt",
+         [12] = "Triel-sur-Seine | Vaux-sur-Seine",
+         [13] = "Meulan Hadricourt | Thun-le-Paradis",
+         [14] = "Gargenville | Juziers",
+         [15] = "Issou Porcheville | Limay",
+         [16] = "Breval | Menerville"},
+    [40] =
+        {[1] = "Gare de Lyon",
+         [5] = "Le Vert de Maisons | Maisons-Alfort Alfortville",
+         [6] = "Villeneuve-Prairie",
+         [7] = "Villeneuve-Triage",
+         [8] = "Villeneuve-Saint-Georges",
+         [9] = "Juvisy | Vigneux-sur-Seine",
+         [10] = "Ris-Orangis | Viry-Châtillon",
+         [11] = "Evry Val de Seine | Grand-Bourg",
+         [12] = "Corbeil-Essonnes | Mennecy | Moulin-Galant",
+         [13] = "Ballancourt | Fontenay le Vicomte",
+         [14] = "La Ferte-Alais",
+         [16] = "Boutigny | Maisse",
+         [17] = "Boigneville | Buno-Gironville"},
+    [41] =
+        {[0] = "Musee d'Orsay | Saint-Michel Notre-Dame",
+         [1] = "Gare d'Austerlitz",
+         [2] = "Bibliotheque-Francois Mitterrand",
+         [4] = "Ivry-sur-Seine | Vitry-sur-Seine",
+         [5] = "Choisy-le-Roi | Les Ardoines",
+         [7] = "Villeneuve-le-Roi",
+         [8] = "Ablon",
+         [9] = "Athis-Mons"},
+    [42] =
+        {[9] = "Epinay-sur-Orge | Savigny-sur-Orge",
+         [10] = "Sainte-Genevieve-des-Bois",
+         [11] = "Saint-Michel-sur-Orge",
+         [12] = "Bretigny-sur-Orge | Marolles-en-Hurepoix",
+         [13] = "Bouray | Lardy",
+         [14] = "Chamarande | Etampes | Etrechy",
+         [16] = "Saint-Martin d'Etampes",
+         [17] = "Guillerval"},
+    [43] =
+        {[9] = "Montgeron Crosne | Yerres",
+         [10] = "Brunoy",
+         [11] = "Boussy-Saint-Antoine | Combs-la-Ville Quincy",
+         [12] = "Lieusaint Moissy",
+         [13] = "Cesson | Savigny-le-Temple Nandy",
+         [15] = "Le Mee | Melun",
+         [16] = "Chartrettes | Fontaine-le-Port | Livry-sur-Seine",
+         [17] =
+             "Champagne-sur-Seine | Hericy | La Grande Paroisse | Vernou-sur-Seine | Vulaines-sur-Seine Samoreau"},
+    [44] =
+        {[12] = "Essonnes-Robinson | Villabe",
+         [13] = "Coudray-Montceaux | Le Plessis-Chenet-IBM | Saint-Fargeau",
+         [14] = "Boissise-le-Roi | Ponthierry Pringy",
+         [15] = "Vosves",
+         [16] = "Bois-le-Roi",
+         [17] =
+             "Bagneaux-sur-Loing | Bourron-Marlotte Grez | Fontainebleau-Avon | Montereau | Montigny-sur-Loing | Moret Veneux-les-Sablons | Nemours Saint-Pierre | Saint-Mammes | Souppes | Thomery"},
+    [45] =
+        {[10] = "Grigny-Centre",
+         [11] = "Evry Courcouronnes | Orangis Bois de l'Epine",
+         [12] = "Le Bras-de-Fer - Evry Genopole"},
+    [50] =
+        {[0] = "Haussmann-Saint-Lazare",
+         [1] = "Gare du Nord | Magenta | Paris-Nord",
+         [5] = "Epinay-Villetaneuse | Saint-Denis | Sevres-Rive Gauche",
+         [6] = "La Barre-Ormesson",
+         [7] = "Champ de Courses d'Enghien | Enghien-les-Bains",
+         [8] = "Ermont-Eaubonne | Ermont-Halte | Gros-Noyer Saint-Prix",
+         [9] = "Saint-Leu-La-Foret | Taverny | Vaucelles",
+         [10] = "Bessancourt | Frepillon | Mery",
+         [11] = "Meriel | Valmondois",
+         [12] = "Bruyeres-sur-Oise | Champagne-sur-Oise | L'Isle-Adam Parmain | Persan Beaumont"},
+    [51] =
+        {[4] = "La Courneuve-Aubervilliers | La Plaine-Stade de France",
+         [5] = "Le Bourget",
+         [7] = "Blanc-Mesnil | Drancy",
+         [8] = "Aulnay-sous-Bois",
+         [9] = "Sevran Livry | Vert-Galant",
+         [10] = "Villeparisis",
+         [11] = "Compans | Mitry-Claye",
+         [12] = "Dammartin Juilly Saint-Mard | Thieux Nantouillet"},
+    [52] =
+        {[5] = "Stade de France-Saint-Denis",
+         [6] = "Pierrefitte Stains",
+         [7] = "Garges-Sarcelles",
+         [8] = "Villiers-le-Bel (Gonesse - Arnouville)",
+         [10] = "Goussainville | Les Noues | Louvres",
+         [11] = "La Borne-Blanche | Survilliers-Fosses"},
+    [53] =
+        {[6] = "Deuil Montmagny",
+         [7] = "Groslay",
+         [8] = "Sarcelles Saint-Brice",
+         [9] = "Domont | Ecouen Ezanville",
+         [10] = "Bouffemont Moisselles | Montsoult Maffliers",
+         [11] = "Belloy-Saint-Martin | Luzarches | Seugy | Viarmes | Villaines"},
+    [54] =
+        {[8] = "Cernay",
+         [9] = "Franconville Plessis-Bouchard | Montigny-Beauchamp",
+         [10] = "Pierrelaye",
+         [11] = "Pontoise | Saint-Ouen-l'Aumone-Liesse",
+         [12] = "Boissy-l'Aillerie | Osny",
+         [15] = "Chars | Montgeroult Courcelles | Santeuil Le Perchay | Us"},
+    [55] =
+        {[0] =
+             "Avenue Foch | Avenue Henri-Martin | Boulainvilliers | Kennedy Radio-France | Neuilly-Porte Maillot (Palais des congres)",
+         [1] = "Pereire-Levallois",
+         [2] = "Porte de Clichy",
+         [3] = "Saint-Ouen",
+         [4] = "Les Gresillons",
+         [5] = "Gennevilliers",
+         [6] = "Epinay-sur-Seine",
+         [7] = "Saint-Gratien"},
+    [56] = {[11] = "Auvers-sur-Oise | Chaponval | Epluches | Pont Petit"},
+    [57] = {[11] = "Presles Courcelles", [12] = "Nointel Mours"},
+    [60] =
+        {[1] = "Gare Montparnasse",
+         [4] = "Clamart | Vanves Malakoff",
+         [5] = "Bellevue | Bievres | Meudon",
+         [6] = "Chaville-Rive Gauche | Chaville-Velizy | Viroflay-Rive Gauche",
+         [7] = "Versailles-Chantiers",
+         [10] = "Saint-Cyr",
+         [11] = "Saint-Quentin-en-Yvelines - Montigny le Bretonneux | Trappes",
+         [12] = "Coignieres | La Verriere",
+         [13] = "Les Essarts-le-Roi",
+         [14] = "Le Perray | Rambouillet",
+         [15] = "Gazeran"},
+    [61] =
+        {[10] = "Fontenay-le-Fleury",
+         [11] = "Villepreux Les-Clayes",
+         [12] = "Plaisir Grignon | Plaisir Les-Clayes",
+         [13] = "Beynes | Mareil-sur-Mauldre | Maule | Nezel Aulnay",
+         [15] =
+             "Garancieres La-Queue | Montfort-l'Amaury Mere | Orgerus Behoust | Tacoigneres Richebourg | Villiers Neauphle Pontchartrain",
+         [16] = "Houdan"},
+    [63] = {[7] = "Porchefontaine | Versailles-Rive Gauche"},
+    [64] =
+        {[0] = "Invalides | Pont de l'alma",
+         [1] = "Champ de Mars-Tour Eiffel",
+         [2] = "Javel",
+         [3] = "Boulevard Victor - Pont du Garigliano | Issy-Val de Seine | Issy",
+         [5] = "Meudon-Val-Fleury"},
+    [65] =
+        {[8] = "Jouy-en-Josas | Petit-Jouy-les-Loges",
+         [9] = "Vauboyen",
+         [10] = "Igny",
+         [11] = "Massy-Palaiseau",
+         [12] = "Longjumeau",
+         [13] = "Chilly-Mazarin",
+         [14] = "Gravigny-Balizy | Petit-Vaux"},
+    [70] =
+        {[9] = "Parc des Expositions | Sevran-Beaudottes | Villepinte",
+         [10] = "Aeroport Charles de Gaulle"},
+    [72] = {[7] = "Sannois"},
+    [73] = {[11] = "Eragny Neuville | Saint-Ouen-l'Aumone (Eglise)"},
+    [75] =
+        {[7] = "Les Saules | Orly-Ville",
+         [9] = "Pont de Rungis Aeroport d'Orly | Rungis-La Fraternelle",
+         [10] = "Chemin d'Antony",
+         [12] = "Massy-Verrieres | Arpajon"},
+    [76] =
+        {[12] = "Egly | La Norville Saint-Germain-les-Arpajon",
+         [13] = "Breuillet Bruyeres-le-Châtel | Breuillet-Village | Saint-Cheron",
+         [14] = "Sermaise",
+         [15] = "Dourdan | Dourdan-la-Foret"},
+};
 
 
 #endif // METRO_LIST_H
 #endif // METRO_LIST_H

+ 73 - 0
metroflip/scenes/navigo_structs.h

@@ -0,0 +1,73 @@
+#include <datetime.h>
+#include <stdbool.h>
+#include <furi.h>
+
+typedef struct {
+    int transport_type;
+    int transition;
+    int service_provider;
+    int station_group_id;
+    int station_id;
+    int location_gate;
+    bool location_gate_available;
+    int device;
+    int door;
+    int side;
+    bool device_available;
+    int route_number;
+    bool route_number_available;
+    int mission;
+    bool mission_available;
+    int vehicle_id;
+    bool vehicle_id_available;
+    int used_contract;
+    bool used_contract_available;
+    DateTime date;
+} NavigoCardEvent;
+
+typedef struct {
+    int app_version;
+    int country_num;
+    int network_num;
+    DateTime end_dt;
+} NavigoCardEnv;
+
+typedef struct {
+    int card_status;
+    int commercial_id;
+} NavigoCardHolder;
+
+typedef struct {
+    int tariff;
+    int serial_number;
+    bool serial_number_available;
+    int pay_method;
+    bool pay_method_available;
+    double price_amount;
+    bool price_amount_available;
+    DateTime start_date;
+    DateTime end_date;
+    bool end_date_available;
+    int zones[5];
+    bool zones_available;
+    DateTime sale_date;
+    int sale_agent;
+    int sale_device;
+    int status;
+    int authenticator;
+} NavigoCardContract;
+
+typedef struct {
+    NavigoCardEnv environment;
+    NavigoCardHolder holder;
+    NavigoCardContract contracts[2];
+    NavigoCardEvent events[3];
+    int ticket_count;
+} NavigoCardData;
+
+typedef struct {
+    NavigoCardData* card;
+    int page_id;
+    // mutex
+    FuriMutex* mutex;
+} NavigoContext;

BIN
metroflip/screenshots/Menu-Top.png


BIN
metroflip/screenshots/Navigo.png


BIN
metroflip/screenshots/Navigo2.png


Some files were not shown because too many files changed in this diff