From 07f6b78c22ac0aa4d61154748681b18e94daebc3 Mon Sep 17 00:00:00 2001 From: Kevin Alberts Date: Tue, 12 Jul 2022 02:29:58 +0200 Subject: [PATCH] Rewrite to normal websockets, add support for Windows and add a GUI mode, to ultimately replace JulianaNFC_C --- .gitignore | 1 - README.md | 50 +++++- gui/about.py | 55 +++++++ gui/controller.py | 120 ++++++++++++++ juliana.py | 328 +++++++++++++++++++++++++++++--------- juliananfc.spec | 34 ++++ juliananfc_python.service | 2 +- requirements.txt | 5 +- resources/main.ico | Bin 0 -> 16958 bytes resources/main.png | Bin 0 -> 5861 bytes resources/splash.png | Bin 0 -> 13788 bytes templates/index.html | 76 --------- 12 files changed, 512 insertions(+), 159 deletions(-) create mode 100644 gui/about.py create mode 100644 gui/controller.py create mode 100644 juliananfc.spec create mode 100644 resources/main.ico create mode 100644 resources/main.png create mode 100644 resources/splash.png delete mode 100644 templates/index.html diff --git a/.gitignore b/.gitignore index 2fb59b2..0d663b8 100644 --- a/.gitignore +++ b/.gitignore @@ -31,7 +31,6 @@ MANIFEST # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest -*.spec # Installer logs pip-log.txt diff --git a/README.md b/README.md index 2b707ee..bbcd913 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,55 @@ # JulianaNFC -JulianaNFC is een Python-programmaatje dat pollt voor NFC tags en deze vervolgens over een WebSocket verstuurt. Werkt alleen onder Linux voor zover bekend. +JulianaNFC is een Python-programmaatje voor Windows en Linux dat pollt voor NFC tags en deze vervolgens over een WebSocket verstuurt. ## Hoe dan? -Installeer onder Linux de PC/SC Smart Card daemon ( [PCSClite](https://pcsclite.alioth.debian.org/pcsclite.html) - [Debian PKG](https://packages.debian.org/source/stretch/pcsc-lite) ) en zorg dat deze gestart is. Installeer de requirements uit requirements.txt en start juliana.py. Vanuit een browser connect je met de WebSocket. - socket = new WebSocket("ws://localhost:3000", "nfc"); +### Windows +Geen requirements, Windows heeft een ingebouwde PC/SC Smart Card service. -Als er een kaart wordt gescand ontvang je een JSON-object met kaartinfo over de socket. +- Download de [release executable](https://github.com/Inter-Actief/JulianaNFC_Python/releases) voor Windows (`JulianaNFC_vX.X.exe`) +- Zet hem op een mooi plekje neer +- Start daarna `JulianaNFC_vX.X.exe`, de GUI zal starten. - {"atqa":"12:34", "uid":"ab:cd:ef:gh", "sak":"56"} +### Linux + +#### Requirements +Installeer onder Linux de PC/SC Smart Card daemon ( [PCSClite](https://pcsclite.alioth.debian.org/pcsclite.html) - [Debian PKG](https://packages.debian.org/source/stretch/pcsc-lite) ) en zorg dat deze gestart is. + +#### JulianaNFC - Optie 1 +- Download de [release executable](https://github.com/Inter-Actief/JulianaNFC_Python/releases) voor Linux (`JulianaNFC_linux_vX.X`) +- Zet hem op een mooi plekje neer +- Start het executable bestand `JulianaNFC_linux_vX.X`, de GUI zal starten. (draai het met de `-h` flag voor CLI-opties) + +#### JulianaNFC - Optie 2 +- Clone of download de code van github +- Maak een virtualenv als je dat graag wilt +- Installeer de requirements uit requirements.txt +- Voer `python juliana.py` uit, de GUI zal starten. (zie `juliana.py -h` voor CLI-opties) + +### Websocket +Vanuit een browser connect je met de WebSocket. + + socket = new WebSocket('ws://localhost:3000', 'nfc'); + socket.onmessage = function (event) { + var rfid = JSON.parse(event.data); + console.log("Tag scanned!"); + console.log(rfid); + }; + +Als er een kaart wordt gescand ontvang je in het 'nfc_read' event een JSON-object met kaartinfo over de socket. + + {"type": "iso-x", "atqa":"12:34", "uid":"ab:cd:ef:gh", "sak":"56"} En dat is alles wat JulianaNFC doet. ## Bugs en to-do's +Bij het scannen van sommige kaarten (i.e. ID-kaarten) breekt de NFC lezer (onder Linux). Even de lezer opnieuw inpluggen fixt het dan vaak. -WebSockets zijn zéér gebrekkig geïmplementeerd. Zodra iets niet volgens plan gebeurt crasht JulianaNFC. +## Package bouwen +- Zoek een computer met het gewenste doelbesturingssysteem en installeer Python en alle requirements voor JulianaNFC. + - Noot: Gebruik bij packagen op Windows een python-versie waarvoor een [.whl van wxPython](https://pypi.org/project/wxPython/#files) beschikbaar is, dit package zelf bouwen op Windows is lastig. +- Installeer pyinstaller (`pip install pyinstaller`) (Getest met 5.2, maar zou met nieuwere prima moeten werken) +- Open een command prompt / terminal en ga naar de map waar JulianaNFC staat +- Verwijder de `build` en `dist` folders als deze nog bestaan van een vorige build +- Draai Pyinstaller op de specfile (`pyinstaller juliana_nfc.spec`) +- De build zal in de `build` folder plaatsvinden, de executable zal in de `dist` folder geplaatst worden. diff --git a/gui/about.py b/gui/about.py new file mode 100644 index 0000000..c3a7a8a --- /dev/null +++ b/gui/about.py @@ -0,0 +1,55 @@ +import wx +import wx.adv + +from juliana import resource_path + +class AboutDialog(wx.Dialog): + def __init__(self, *args, **kwargs): + from juliana import APP_SUPPORT, APP_LINK + kwargs["style"] = kwargs.get("style", 0) | wx.DEFAULT_DIALOG_STYLE + wx.Dialog.__init__(self, *args, **kwargs) + self.SetSize((400, 350)) + self.SetIcon(wx.Icon(resource_path("resources/main.png"), type=wx.BITMAP_TYPE_PNG)) + self.hyperlink_2 = wx.adv.HyperlinkCtrl(self, wx.ID_ANY, APP_LINK, APP_LINK, style=wx.adv.HL_ALIGN_CENTRE) + self.hyperlink_3 = wx.adv.HyperlinkCtrl(self, wx.ID_ANY, APP_SUPPORT, f"mailto://{APP_SUPPORT}", style=wx.adv.HL_ALIGN_CENTRE) + self.button_2 = wx.Button(self, wx.ID_OK, "") + + self.__set_properties() + self.__do_layout() + + def __set_properties(self): + self.SetTitle(f"About JulianaNFC") + self.SetSize((400, 350)) + + def __do_layout(self): + from juliana import APP_NAME, APP_VERSION, APP_AUTHOR + grid_sizer_1 = wx.BoxSizer(wx.VERTICAL) + grid_sizer_1.Add((0, 10), 0, wx.EXPAND, 0) + label_1 = wx.StaticText(self, wx.ID_ANY, f"{APP_NAME} v{APP_VERSION}", style=wx.ALIGN_CENTER) + label_1.SetFont(wx.Font(18, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_BOLD, 0, "")) + grid_sizer_1.Add(label_1, 0, wx.ALIGN_CENTER, 8) + grid_sizer_1.Add((0, 8), 0, wx.EXPAND, 0) + static_line_1 = wx.StaticLine(self, wx.ID_ANY) + grid_sizer_1.Add(static_line_1, 0, wx.ALL | wx.EXPAND, 0) + grid_sizer_1.Add((0, 8), 0, wx.EXPAND, 0) + label_2 = wx.StaticText(self, wx.ID_ANY, f"JulianaNFC is a small tray application that allows scanning " + f"NFC cards to a websocket for use in web applications.", style=wx.ALIGN_CENTER) + label_2.Wrap(320) + grid_sizer_1.Add(label_2, 0, wx.ALIGN_CENTER, 8) + grid_sizer_1.Add((0, 8), 0, wx.EXPAND, 0) + label_3 = wx.StaticText(self, wx.ID_ANY, f"JulianaNFC was created by {APP_AUTHOR}", style=wx.ALIGN_CENTER) + label_3.Wrap(320) + grid_sizer_1.Add(label_3, 0, wx.ALIGN_CENTER, 8) + grid_sizer_1.Add((0, 8), 0, wx.EXPAND, 0) + label_6 = wx.StaticText(self, wx.ID_ANY, "For more information, check the GitHub:", style=wx.ALIGN_CENTER) + grid_sizer_1.Add(label_6, 0, wx.ALIGN_CENTER, 0) + grid_sizer_1.Add(self.hyperlink_2, 0, wx.ALIGN_CENTER, 0) + label_7 = wx.StaticText(self, wx.ID_ANY, "For support, mail the WWW-committee:", style=wx.ALIGN_CENTER) + grid_sizer_1.Add(label_7, 0, wx.ALIGN_CENTER, 0) + grid_sizer_1.Add(self.hyperlink_3, 0, wx.ALIGN_CENTER, 0) + grid_sizer_1.Add((0, 10), 0, wx.EXPAND, 0) + grid_sizer_1.Add(self.button_2, 0, wx.ALIGN_CENTER, 0) + grid_sizer_1.Add((0, 10), 0, wx.EXPAND, 0) + self.SetSizer(grid_sizer_1) + grid_sizer_1.Fit(self) + self.Layout() diff --git a/gui/controller.py b/gui/controller.py new file mode 100644 index 0000000..4c3d672 --- /dev/null +++ b/gui/controller.py @@ -0,0 +1,120 @@ +import wx +import wx.adv +import signal +import os +import time +import logging + +from juliana import resource_path + + +logging.basicConfig(level=logging.INFO) + + +class JulianaTrayIcon(wx.adv.TaskBarIcon): + def __init__(self, frame): + self.frame = frame + super(JulianaTrayIcon, self).__init__() + self.SetIcon(wx.Icon(resource_path("resources/main.png"), type=wx.BITMAP_TYPE_PNG), "JulianaNFC") + self.Bind(wx.adv.EVT_TASKBAR_LEFT_DOWN, self.on_left_click) + + def CreatePopupMenu(self): + self.menu = wx.Menu() + self.create_menu_text(self.menu, "JulianaNFC") + self.menu.AppendSeparator() + self.create_menu_item(self.menu, "About", self.on_about) + self.create_menu_item(self.menu, "Exit", self.on_exit) + return self.menu + + def create_menu_item(self, menu, label, callback, icon=None): + item = wx.MenuItem(menu, -1, label) + menu.Bind(wx.EVT_MENU, callback, id=item.GetId()) + menu.Append(item) + if icon is not None: + bitmap = wx.Bitmap(icon, type=wx.BITMAP_TYPE_PNG) + item.SetBitmaps(checked=bitmap, unchecked=bitmap) + return item + + def create_menu_text(self, menu, label, icon=None): + item = wx.MenuItem(menu, -1, label) + menu.Append(item) + item.Enable(False) + if icon is not None: + bitmap = wx.Bitmap(icon, type=wx.BITMAP_TYPE_PNG) + item.SetBitmaps(checked=bitmap, unchecked=bitmap) + return item + + def on_left_click(self, event): + if self.frame.IsShown(): + logging.info("Tray icon was left-clicked. Hiding main window") + self.frame.Show(False) + else: + logging.info("Tray icon was left-clicked. Showing main window") + self.frame.Show(True) + + def on_about(self, event): + logging.debug("Tray option about clicked.") + from gui.about import AboutDialog + dialog = AboutDialog(None, wx.ID_ANY, "") + dialog.ShowModal() + dialog.Destroy() + + def on_exit(self, event): + logging.info("Tray option exit clicked. Exiting Juliana") + self.RemoveIcon() + wx.CallAfter(self.Destroy) + self.frame.Close(force=True) + + +class JulianaApp(wx.App): + def __init__(self, *args, **kwargs): + super(JulianaApp, self).__init__(*args, **kwargs) + bitmap = wx.Bitmap(resource_path('resources/splash.png')) + self.splash = wx.adv.SplashScreen(bitmap, wx.adv.SPLASH_CENTER_ON_SCREEN | wx.adv.SPLASH_TIMEOUT, 3500, self.frame) + + def OnInit(self): + from juliana import APP_NAME, APP_VERSION, APP_AUTHOR, APP_SUPPORT + self.frame = wx.Frame(None, title="JulianaNFC", size=(640, 480)) + self.frame.SetIcon(wx.Icon(resource_path("resources/main.png"), type=wx.BITMAP_TYPE_PNG)) + + self.panel = wx.Panel(self.frame) + + self.console = wx.TextCtrl(self.panel, style=(wx.TE_MULTILINE | wx.TE_READONLY | wx.TE_BESTWRAP)) + self.console.AppendText(f"{APP_NAME} v{APP_VERSION} (By {APP_AUTHOR})\n") + self.console.AppendText(f"Support: {APP_SUPPORT}\n\n") + + self.horizontal = wx.BoxSizer(wx.HORIZONTAL) + self.horizontal.Add((8, 0), 0, wx.EXPAND, 0) + self.horizontal.Add(self.console, proportion=1, flag=wx.EXPAND) + self.horizontal.Add((8, 0), 0, wx.EXPAND, 0) + + self.vertical = wx.BoxSizer(wx.VERTICAL) + self.vertical.Add((0, 8), 0, wx.EXPAND, 0) + self.vertical.Add(self.horizontal, proportion=1, flag=wx.EXPAND) + self.vertical.Add((0, 8), 0, wx.EXPAND, 0) + + self.panel.SetSizerAndFit(self.vertical) + self.frame.Bind(wx.EVT_CLOSE, self.OnClose) + + self.SetTopWindow(self.frame) + JulianaTrayIcon(self.frame) + return True + + def OnClose(self, evt): + if evt.CanVeto(): + logging.info("Main window closed. Hiding to systray") + self.frame.Show(False) + evt.Veto() + else: + logging.info("Main window closed and we must stop. Exiting Juliana") + wx.CallAfter(self.Destroy) + evt.Skip() + + def OnExit(self): + os.kill(os.getpid(), signal.SIGINT) + time.sleep(1) + os.kill(os.getpid(), signal.SIGKILL) + return 0 + + def add_message(self, message): + self.console.AppendText(f"{message}\n") diff --git a/juliana.py b/juliana.py index 911a59e..f3accd6 100644 --- a/juliana.py +++ b/juliana.py @@ -1,56 +1,110 @@ #!/usr/bin/python3 -u +import os +import sys +import json +import time import random import string +import logging import traceback +import threading +from datetime import datetime +from subprocess import Popen + +import smartcard.System from smartcard.CardConnection import CardConnection from smartcard.CardConnectionObserver import CardConnectionObserver from smartcard.CardMonitoring import CardMonitor, CardObserver +from smartcard.ReaderMonitoring import ReaderMonitor, ReaderObserver +from smartcard.Exceptions import CardConnectionException + +from flask import Flask, render_template, request +from flask_sock import Sock + + +APP_VERSION = "3.0" +APP_NAME = "JulianaNFC" +APP_AUTHOR = "Kevin Alberts, I.C.T.S.V. Inter-/Actief/" +APP_SUPPORT = "www@inter-actief.net" +APP_LINK = "https://github.com/Inter-Actief/JulianaNFC_Python" + + +DEBUG = False +HAS_GUI = False +DO_KIOSK_XSET = False +gui_app = None + + +logging.basicConfig(level=logging.INFO) -from flask import Flask, render_template -from flask_socketio import SocketIO, emit -from subprocess import Popen + +def print_console(message, level="info"): + if level == "debug": + logging.debug(message) + elif level == "info": + logging.info(message) + elif level == "warning": + logging.warning(message) + elif level == "error": + logging.error(message) + elif level == "critical": + logging.critical(message) + if HAS_GUI: + global gui_app + gui_app.add_message(f"[{datetime.now():%H:%M:%S}] {message}") + + +def resource_path(relative): + return os.path.join(getattr(sys, "_MEIPASS", os.path.abspath(".")), relative) + + +def notify_toast(title, message, app_icon="resources/main.ico", timeout=3): + if HAS_GUI: + from plyer import notification + notification.notify(title=title, message=message, app_icon=resource_path(app_icon), timeout=timeout) def debug_desfire_version(version): # Referenced from: # https://github.com/EsupPortail/esup-nfc-tag-server/blob/d27858c653635093b670d1a5a68f742b51176207/src/main/java/nfcjlib/core/DESFireEV1.java#L2106 # hardware info - print("Hardware info") - print(" vendor: {:02x}".format(version[0]), end="") - print(" (NXP)" if version[0] == 0x04 else '') - print(" type/subtype: {:02x}/{:02x}".format(version[1], version[2])) - print(" version: {}.{}".format(version[3], version[4])) - exp = (version[5] >> 1); - print(" storage size: {}".format(2 ** exp), end="") - print(" bytes" if version[5] & 0x01 == 0 else " to {} bytes".format(2 ** (exp + 1))) - print(" protocol: 0x{:02x}".format(version[6])) - print(" ISO 14443-3 and ISO 14443-4" if version[6] == 0x05 else "") + print_console("Hardware info", level="debug") + print_console(" vendor: {:02x}".format(version[0]), level="debug") + print_console(" (NXP)" if version[0] == 0x04 else '', level="debug") + print_console(" type/subtype: {:02x}/{:02x}".format(version[1], version[2]), level="debug") + print_console(" version: {}.{}".format(version[3], version[4]), level="debug") + exp = (version[5] >> 1) + print_console(" storage size: {}".format(2 ** exp), level="debug") + print_console(" bytes" if version[5] & 0x01 == 0 else " to {} bytes".format(2 ** (exp + 1)), level="debug") + print_console(" protocol: 0x{:02x}".format(version[6]), level="debug") + print_console(" ISO 14443-3 and ISO 14443-4" if version[6] == 0x05 else "", level="debug") # software info - print("Software info") - print(" vendor: {:02x}".format(version[7]), end="") - print(" (NXP)" if version[7] == 0x04 else '') - print(" type/subtype: {:02x}/{:02x}".format(version[8], version[9])) - print(" version: {}.{}".format(version[10], version[11])) + print_console("Software info", level="debug") + print_console(" vendor: {:02x}".format(version[7]), level="debug") + print_console(" (NXP)" if version[7] == 0x04 else '', level="debug") + print_console(" type/subtype: {:02x}/{:02x}".format(version[8], version[9]), level="debug") + print_console(" version: {}.{}".format(version[10], version[11]), level="debug") exp = (version[12] >> 1); - print(" storage size: {}".format(2 ** exp), end="") - print(" bytes" if version[12] & 0x01 == 0 else " to {} bytes".format(2 ** (exp + 1))) - print(" protocol: 0x{:02x}".format(version[13])) - print(" ISO 14443-3 and ISO 14443-4" if version[6] == 0x05 else "") + print_console(" storage size: {}".format(2 ** exp), level="debug") + print_console(" bytes" if version[12] & 0x01 == 0 else " to {} bytes".format(2 ** (exp + 1)), level="debug") + print_console(" protocol: 0x{:02x}".format(version[13]), level="debug") + print_console(" ISO 14443-3 and ISO 14443-4" if version[6] == 0x05 else "", level="debug") # other info - print("Other info") - print(" batch number: {}".format(":".join("{:02x}".format(x) for x in version[21:26]))) - print(" UID: {}".format(":".join("{:02x}".format(x) for x in version[14:21]))) - print(" production: week 0x{:02x}, year 0x{:02x} ???".format(version[22], version[23])) + print_console("Other info", level="debug") + print_console(" batch number: {}".format(":".join("{:02x}".format(x) for x in version[21:26])), level="debug") + print_console(" UID: {}".format(":".join("{:02x}".format(x) for x in version[14:21])), level="debug") + print_console(" production: week 0x{:02x}, year 0x{:02x} ???".format(version[22], version[23]), level="debug") def process_desfire_response(cardconnection, version_info): try: if version_info[10] not in [0x01, 0x02]: - debug_desfire_version(version_info) + if DEBUG: + debug_desfire_version(version_info) raise IndexError("Only DESFire EV1 and EV2 supported for now (found version {})".format(version_info[10])) uid = version_info[14:21] @@ -59,7 +113,7 @@ def process_desfire_response(cardconnection, version_info): raise IndexError("DESFire EV{} card with all-zero UID found (random UID mode)".format(version_info[10])) send_nfc_tag({ - # "type": "iso-a", + "type": "iso-4", "uid": ":".join("{:02x}".format(x) for x in uid), "atqa": "{:02x}:{:02x}".format(0x03, 0x44), # Only with iso-a "sak": "{:02x}".format(0x20), # Only with iso-a @@ -67,12 +121,13 @@ def process_desfire_response(cardconnection, version_info): # Beep card reader and set LED to green. cardconnection.transmit(bytes=[0xFF, 0x00, 0x40, 0x34, 0x04, 0x02, 0x02, 0x01, 0x01]) - except IndexError: + except IndexError as e: traceback.print_exc() - print("Invalid card scan, please retry") + print_console(f"Invalid card scan - {e}", level="error") + notify_toast(title="Invalid card scan", message=f"{e}") -def process_classic_response(cardconnection, rdata, sw1, sw2): +def process_classic_a_response(cardconnection, rdata, sw1, sw2): try: sak = rdata[6] uidlen = rdata[7] @@ -80,7 +135,7 @@ def process_classic_response(cardconnection, rdata, sw1, sw2): uid = rdata[8:(8 + uidlen)] send_nfc_tag({ - # "type": "iso-a", + "type": "iso-a", "uid": ":".join("{:02x}".format(x) for x in uid), "atqa": "{:02x}:{:02x}".format(atqa[0], atqa[1]), # Only with iso-a "sak": "{:02x}".format(sak), # Only with iso-a @@ -88,83 +143,210 @@ def process_classic_response(cardconnection, rdata, sw1, sw2): # Beep card reader and set LED to green. cardconnection.transmit(bytes=[0xFF, 0x00, 0x40, 0x34, 0x04, 0x02, 0x02, 0x01, 0x01]) - except IndexError: + except IndexError as e: traceback.print_exc() - print("Invalid card scan, please retry") + print_console(f"Invalid card scan, please retry - {e}", level="error") + notify_toast(title="Invalid card scan", message=f"Please retry the last card scan - {e}") -class RfidCardObserver(CardObserver): - def __init__(self): - super().__init__() - self.buzzer_off = False +def process_classic_b_response(cardconnection, atr): + try: + uidlen = 4 + uid = atr[5:(5 + uidlen)] + + send_nfc_tag({ + "type": "iso-b", + "uid": ":".join("{:02x}".format(x) for x in uid) + }) + + # Beep card reader and set LED to green. + cardconnection.transmit(bytes=[0xFF, 0x00, 0x40, 0x34, 0x04, 0x02, 0x02, 0x01, 0x01]) + except IndexError as e: + traceback.print_exc() + print_console(f"Invalid card scan, please retry - {e}", level="error") + notify_toast(title="Invalid card scan", message=f"Please retry the last card scan - {e}") + +class RfidReaderObserver(ReaderObserver): + def update(self, observable, actions): + (added, removed) = actions + for reader in added: + print_console(f"Card reader found: {reader}", level="warning") + notify_toast(title="Card reader connected", message=f"{reader} is now available") + for reader in removed: + print_console(f"Card reader removed: {reader}", level="warning") + notify_toast(title="Card reader disconnected", message=f"{reader} is no longer available") + if not smartcard.System.readers(): + print_console(f"No readers found.") + + +class RfidCardObserver(CardObserver): def update(self, observable, actions): (added, removed) = actions if added: for card in added: conn = card.createConnection() - conn.connect() + try: + conn.connect() + except CardConnectionException as e: + traceback.print_exc() + print_console(f"Could not connect to card, please retry - {e}", level="error") + notify_toast(title="Invalid card scan", message=f"Please retry the last card scan\n{e.__class__.__name__}: {e}") + continue # Set card reader LED to orange. conn.transmit(bytes=[0xFF, 0x00, 0x40, 0x3C, 0x04, 0x01, 0x01, 0x01, 0x00]) # If we didn't turn the buzzer off yet, disable it. conn.transmit(bytes=[0xFF, 0x00, 0x52, 0x00, 0x00]) - # if not self.buzzer_off: - # self.buzzer_off = True # Ask the card for its ATR - atr_str = "".join("{:02x}".format(x) for x in conn.getATR()) + try: + atr = conn.getATR() + except CardConnectionException as e: + traceback.print_exc() + print_console(f"Invalid card scan, please retry - {e}", level="error") + notify_toast(title="Invalid card scan", message=f"Please retry the last card scan\n{e.__class__.__name__}: {e}") + continue + + atr_str = "".join("{:02x}".format(x) for x in atr) if atr_str == "3b8180018080": # DESFire card, retrieve data in a different way + # Keep retrying until success, as it can be the card was improperly shut down, + # or the connection is bad, which can make these multiple commands fail pretty easily. # References: # - https://stackoverflow.com/questions/29819356/apdu-for-getting-uid-from-mifare-desfire # - https://stackoverflow.com/questions/40101316/whats-the-difference-between-desfire-and-desfire-ev1-cards # - https://stackoverflow.com/questions/15967255/how-to-get-sak-to-identify-smart-card-type-using-java # - https://smartcard-atr.apdu.fr/parse?ATR=3b8180018080 - version_info = [] - try: - rdata, s1, s2 = conn.transmit(bytes=[0x90, 0x60, 0x00, 0x00, 0x00]) - version_info.extend(rdata) - rdata, s1, s2 = conn.transmit(bytes=[0x90, 0xAF, 0x00, 0x00, 0x00]) - version_info.extend(rdata) - rdata, s1, s2 = conn.transmit(bytes=[0x90, 0xAF, 0x00, 0x00, 0x00]) - version_info.extend(rdata) - process_desfire_response(conn, version_info) - except IndexError: - traceback.print_exc() - print("Invalid card scan, please retry") - else: - # Probably Mifare Classic, ask it for its UID, ATQA and SAK + scan_success = False + while not scan_success: + version_info = [] + try: + rdata, s1, s2 = conn.transmit(bytes=[0x90, 0x60, 0x00, 0x00, 0x00]) + if s1 != 0x91 or s2 != 0xAF: # If not MORE_DATA + raise ValueError("Failed on initial getVersion") + version_info.extend(rdata) + rdata, s1, s2 = conn.transmit(bytes=[0x90, 0xAF, 0x00, 0x00, 0x00]) + if s1 != 0x91 or s2 != 0xAF: # If not MORE_DATA + raise ValueError("Failed on second getVersion") + version_info.extend(rdata) + rdata, s1, s2 = conn.transmit(bytes=[0x90, 0xAF, 0x00, 0x00, 0x00]) + if s1 != 0x91 or s2 != 0x00: # If not CMD_SUCCESS + raise ValueError("Failed on final getVersion") + version_info.extend(rdata) + process_desfire_response(conn, version_info) + scan_success = True + except ValueError as e: + logging.warning(f"DESFire getVersion fail, retrying: {e}") + except IndexError as e: + traceback.print_exc() + print_console(f"Invalid card scan, please retry - {e}", level="error") + notify_toast(title="Invalid card scan", message=f"Please retry the last card scan\n{e.__class__.__name__}: {e}") + except CardConnectionException as e: + scan_success = True + traceback.print_exc() + print_console(f"Invalid card scan, please retry - {e}", level="error") + notify_toast(title="Invalid card scan", message=f"Please retry the last card scan\n{e.__class__.__name__}: {e}") + elif atr[0] == 0x3b and atr[4] == 0x80: + # MiFare Classic type A rdata, s1, s2 = conn.transmit(bytes=[0xFF, 0x00, 0x00, 0x00, 0x04, 0xD4, 0x4A, 0x01, 0x00]) - process_classic_response(conn, rdata, s1, s2) + process_classic_a_response(conn, rdata, s1, s2) + elif atr[0] == 0x3b and atr[4] == 0x50: + # MiFare Classic type B + process_classic_b_response(conn, atr) + else: + print_console(f"A card was scanned, but the type was unknown. (ATR 0x{atr_str[:12]})", level="error") + notify_toast(title="Unknown card", message="A card was scanned, but the type was unknown.") + conn.disconnect() app = Flask(__name__) app.debug = False app.config['SECRET_KEY'] = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(20)) -socketio = SocketIO(app, cors_allowed_origins='*') +sock = Sock(app) -@app.route('/') -def index(): - return render_template('index.html') +socket_clients = [] -@socketio.on('nfc_echo_test') -def on_message(message): - print("sending back:", message) - emit('nfc_echo_test_response', {'data': message['data']}) +@sock.route('/') +def websocket(ws): + global socket_clients + socket_clients.append(ws) + print_console(f"New connection: {request.remote_addr}", level="info") + try: + while True: + time.sleep(5) + if not ws.connected: + break + except: + pass + socket_clients.remove(ws) + print_console(f"Client disconnected: {request.remote_addr}", level="info") def send_nfc_tag(card): - print("Sending:", card) - Popen(["/bin/su", "kiosk", "-s", "/bin/bash", "-c", "/usr/bin/xset -display :0 dpms force on"]) - socketio.emit('nfc_read', card) + if DO_KIOSK_XSET: + Popen(["/bin/su", "kiosk", "-s", "/bin/bash", "-c", "/usr/bin/xset -display :0 dpms force on"]) + if card['type'] == "iso-a": + print_console(f"Scanned: ISO Type A -- UID<{card['uid']}> ATQA<{card['atqa']}> SAK<{card['sak']}>", level="info") + elif card['type'] == "iso-4": + print_console(f"Scanned: ISO DESFire -- UID<{card['uid']}> ATQA<{card['atqa']}> SAK<{card['sak']}>", level="info") + elif card['type'] == "iso-b": + print_console(f"Scanned: ISO Type B -- UID<{card['uid']}>", level="info") + else: + print_console(f"Scanned: UNKNOWN -- {card}", level="warning") -if __name__ == '__main__': - monitor = CardMonitor() - observer = RfidCardObserver() - monitor.addObserver(observer) - socketio.run(app, port=3000) + global socket_clients + for socket in socket_clients: + socket.send(json.dumps(card)) + +def run_gui(): + global gui_app + from gui.controller import JulianaApp + gui_app = JulianaApp(False) + gui_app.MainLoop() + + +if __name__ == '__main__': + logging.info(f"{APP_NAME} v{APP_VERSION} (By {APP_AUTHOR})") + logging.info(f"Support: {APP_SUPPORT}\n") + + if "-h" in sys.argv: + logging.info(f"Usage: {sys.argv[0]} [-c] [-h] [-k]") + logging.info("----------------------------") + logging.info("-c | Run in CLI mode, no GUI or tray icon") + logging.info("-h | Show this help and exit") + logging.info("-k | Run in Inter-Actief cookie corner kiosk mode (unsupported)") + sys.exit(0) + + HAS_GUI = "-c" not in sys.argv + DO_KIOSK_XSET = "-k" in sys.argv + + if HAS_GUI: + gui_thread = threading.Thread(target=run_gui) + gui_thread.daemon = True + gui_thread.start() + + while gui_app is None: + time.sleep(0.01) + + readers = smartcard.System.readers() + if not readers: + print_console("No readers found.", level="warning") + notify_toast(title="No card reader", message="There are no card readers available") + else: + print_console(f"Found {len(readers)} card readers:", level="info") + for reader in readers: + print_console(f" - {reader}", level="info") + notify_toast(title="Card reader connected", message=f"{reader} is now available") + + reader_monitor = ReaderMonitor() + reader_observer = RfidReaderObserver() + reader_monitor.addObserver(reader_observer) + card_monitor = CardMonitor() + card_observer = RfidCardObserver() + card_monitor.addObserver(card_observer) + app.run(port=3000) diff --git a/juliananfc.spec b/juliananfc.spec new file mode 100644 index 0000000..15029c5 --- /dev/null +++ b/juliananfc.spec @@ -0,0 +1,34 @@ +# -*- mode: python ; coding: utf-8 -*- + +block_cipher = None + + +a = Analysis(['juliana.py'], + pathex=['.'], + binaries=[], + datas=[('resources', 'resources')], + hiddenimports=['threading', 'time', 'queue', 'werkzeug', 'plyer.platforms.win.notification', 'plyer.platforms.linux.notification'], + hookspath=[], + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False) +pyz = PYZ(a.pure, a.zipped_data, + cipher=block_cipher) +exe = EXE(pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + name='JulianaNFC', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + icon='resources/main.ico') diff --git a/juliananfc_python.service b/juliananfc_python.service index 53fef24..72ef8bb 100644 --- a/juliananfc_python.service +++ b/juliananfc_python.service @@ -6,7 +6,7 @@ After=network.target [Service] Type=simple WorkingDirectory=/opt/JulianaNFC_Python -ExecStart=/usr/bin/python3 /opt/JulianaNFC_Python/juliana.py +ExecStart=/usr/bin/python3 /opt/JulianaNFC_Python/juliana.py -c Restart=always [Install] diff --git a/requirements.txt b/requirements.txt index 7064dd0..e4311f1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ pyscard>=2.0,<2.1 -websockets>=4.0,<4.1 flask -flask_socketio +flask-sock>=0.5,<1.0 +wxPython>=4.1.1 +plyer>=2.0.0,<2.1 diff --git a/resources/main.ico b/resources/main.ico new file mode 100644 index 0000000000000000000000000000000000000000..590b8a9d0923f87c34d9c2745d43c4d3d32fdf68 GIT binary patch literal 16958 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 literal 0 HcmV?d00001 diff --git a/resources/main.png b/resources/main.png new file mode 100644 index 0000000000000000000000000000000000000000..4d2e1278c43477b9831e6f8d0294c09d87fa7eba GIT binary patch literal 5861 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 MBoxIf#S8=gA0}bH7ytkO literal 0 HcmV?d00001 diff --git a/templates/index.html b/templates/index.html deleted file mode 100644 index 61f6f35..0000000 --- a/templates/index.html +++ /dev/null @@ -1,76 +0,0 @@ - - - - - Flask-SocketIO Test - - - - - -

Flask-SocketIO Test

-

Send:

-
- - -
-

Receive:

-
- -