338 lines
11 KiB
Python
338 lines
11 KiB
Python
"""
|
|
MyKad Reader GUI
|
|
|
|
Cross-platform (macOS / Windows) interface for reading a Malaysian MyKad
|
|
through any PC/SC reader, including the ACS ACR39U.
|
|
|
|
REQUIRES: pyscard, tkinter
|
|
OPTIONAL: Pillow (pip install pillow) — in-app photo preview
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import os
|
|
import platform
|
|
import sys
|
|
import threading
|
|
import tkinter as tk
|
|
from tkinter import filedialog, messagebox
|
|
|
|
from API import list_readers
|
|
from mykad import MyKadData, MyKadError, read_mykad_from_reader
|
|
|
|
os.environ.setdefault("TK_SILENCE_DEPRECATION", "1")
|
|
|
|
BG = "#e8e8e8"
|
|
FG = "#111111"
|
|
ENTRY_BG = "#ffffff"
|
|
BUTTON_BG = "#dcdcdc"
|
|
|
|
FIELDS = [
|
|
("Name", "name"),
|
|
("GMPC name", "gmpc_name"),
|
|
("KPT name", "kpt_name"),
|
|
("NRIC", "nric"),
|
|
("Gender", "gender"),
|
|
("Old IC", "old_ic"),
|
|
("Date of birth", "dob"),
|
|
("Age", "age"),
|
|
("Birth place", "birth_place"),
|
|
("Date issued", "date_issued"),
|
|
("Citizenship", "citizenship"),
|
|
("Race", "race"),
|
|
("Religion", "religion"),
|
|
("Address 1", "address1"),
|
|
("Address 2", "address2"),
|
|
("Address 3", "address3"),
|
|
("Postcode", "postcode"),
|
|
("City", "city"),
|
|
("State", "state"),
|
|
("SOCSO", "socso"),
|
|
("Locality", "locality"),
|
|
]
|
|
|
|
|
|
def _load_pillow():
|
|
try:
|
|
from PIL import Image, ImageTk
|
|
return Image, ImageTk
|
|
except ImportError:
|
|
return None, None
|
|
|
|
|
|
class MyKadReaderUI:
|
|
def __init__(self, root: tk.Tk):
|
|
self.root = root
|
|
self.root.title("MyKad Reader")
|
|
self.root.configure(bg=BG)
|
|
try:
|
|
self.root.iconbitmap("ACS_Small.ico")
|
|
except Exception:
|
|
pass
|
|
|
|
self.root.geometry("980x720")
|
|
self.root.minsize(820, 600)
|
|
|
|
self.reader_var = tk.StringVar(master=self.root)
|
|
self.status_var = tk.StringVar(
|
|
master=self.root, value="Insert a MyKad and click Read MyKad."
|
|
)
|
|
self.field_vars = {
|
|
key: tk.StringVar(master=self.root) for _, key in FIELDS
|
|
}
|
|
self.current_data = None
|
|
self._photo_image = None
|
|
self._reading = False
|
|
self._pil_image = None
|
|
self._pil_imagetk = None
|
|
self._reader_names = []
|
|
|
|
self._build()
|
|
self.root.after_idle(lambda: self.refresh_readers(warn=False))
|
|
|
|
def _frame(self, parent, **kwargs):
|
|
options = {"bg": BG, "bd": 1, "relief": "groove", "padx": 8, "pady": 8}
|
|
options.update(kwargs)
|
|
return tk.Frame(parent, **options)
|
|
|
|
def _label(self, parent, **kwargs):
|
|
options = {"bg": BG, "fg": FG, "anchor": "w"}
|
|
options.update(kwargs)
|
|
return tk.Label(parent, **options)
|
|
|
|
def _button(self, parent, **kwargs):
|
|
options = {
|
|
"bg": BUTTON_BG,
|
|
"fg": FG,
|
|
"activebackground": "#c8c8c8",
|
|
"activeforeground": FG,
|
|
"highlightbackground": BG,
|
|
"padx": 8,
|
|
}
|
|
options.update(kwargs)
|
|
return tk.Button(parent, **options)
|
|
|
|
def _entry(self, parent, textvariable):
|
|
return tk.Entry(
|
|
parent,
|
|
textvariable=textvariable,
|
|
bg=ENTRY_BG,
|
|
fg=FG,
|
|
insertbackground=FG,
|
|
highlightbackground=BG,
|
|
relief="sunken",
|
|
readonlybackground=ENTRY_BG,
|
|
state="readonly",
|
|
)
|
|
|
|
def _build(self):
|
|
status = self._label(
|
|
self.root, textvariable=self.status_var, anchor="w", padx=12, pady=8
|
|
)
|
|
status.pack(fill="x", side="bottom")
|
|
|
|
reader_box = self._frame(self.root)
|
|
reader_box.pack(fill="x", padx=10, pady=10, side="top")
|
|
self._label(reader_box, text="Smart Card Reader").pack(anchor="w")
|
|
|
|
row = tk.Frame(reader_box, bg=BG)
|
|
row.pack(fill="x", pady=(6, 0))
|
|
self._label(row, text="Select reader:").pack(side="left", padx=(0, 6))
|
|
|
|
self.reader_option = tk.OptionMenu(row, self.reader_var, "")
|
|
self.reader_option.configure(
|
|
bg=BUTTON_BG,
|
|
fg=FG,
|
|
activebackground="#c8c8c8",
|
|
activeforeground=FG,
|
|
highlightbackground=BG,
|
|
width=40,
|
|
)
|
|
self.reader_option["menu"].configure(bg=ENTRY_BG, fg=FG)
|
|
self.reader_option.pack(side="left", fill="x", expand=True, padx=4)
|
|
|
|
self._button(row, text="Refresh", command=lambda: self.refresh_readers(True)).pack(
|
|
side="left", padx=4
|
|
)
|
|
self._button(row, text="Read MyKad", command=self.read_card).pack(side="left", padx=4)
|
|
self._button(row, text="Save Photo...", command=self.save_photo).pack(
|
|
side="left", padx=4
|
|
)
|
|
|
|
body = tk.Frame(self.root, bg=BG)
|
|
body.pack(fill="both", expand=True, padx=10, pady=(0, 8))
|
|
|
|
photo_box = self._frame(body)
|
|
photo_box.pack(side="left", fill="y", padx=(0, 10))
|
|
self._label(photo_box, text="Photo").pack(anchor="w")
|
|
self.photo_label = tk.Label(
|
|
photo_box,
|
|
text="No photo",
|
|
width=24,
|
|
height=16,
|
|
bg=ENTRY_BG,
|
|
fg=FG,
|
|
relief="sunken",
|
|
bd=1,
|
|
)
|
|
self.photo_label.pack(pady=(6, 0))
|
|
|
|
form_box = self._frame(body)
|
|
form_box.pack(side="left", fill="both", expand=True)
|
|
self._label(form_box, text="JPN data").pack(anchor="w")
|
|
|
|
grid = tk.Frame(form_box, bg=BG)
|
|
grid.pack(fill="both", expand=True, pady=(6, 0))
|
|
grid.columnconfigure(1, weight=1)
|
|
grid.columnconfigure(3, weight=1)
|
|
|
|
midpoint = (len(FIELDS) + 1) // 2
|
|
for index, (label, key) in enumerate(FIELDS):
|
|
if index < midpoint:
|
|
row_i, col = index, 0
|
|
else:
|
|
row_i, col = index - midpoint, 2
|
|
self._label(grid, text=f"{label}:").grid(
|
|
row=row_i, column=col, sticky="w", padx=(4, 6), pady=3
|
|
)
|
|
self._entry(grid, self.field_vars[key]).grid(
|
|
row=row_i, column=col + 1, sticky="ew", padx=(0, 10), pady=3
|
|
)
|
|
|
|
def _set_reader_options(self, names):
|
|
menu = self.reader_option["menu"]
|
|
menu.delete(0, "end")
|
|
self._reader_names = list(names)
|
|
if not names:
|
|
self.reader_var.set("")
|
|
return
|
|
for name in names:
|
|
menu.add_command(label=name, command=lambda n=name: self.reader_var.set(n))
|
|
if self.reader_var.get() not in names:
|
|
self.reader_var.set(names[0])
|
|
|
|
def refresh_readers(self, warn=True):
|
|
readers = list_readers()
|
|
names = [str(reader) for reader in readers]
|
|
self._set_reader_options(names)
|
|
if names:
|
|
self.status_var.set(
|
|
f"{len(names)} reader(s) found. Insert a MyKad and click Read MyKad."
|
|
)
|
|
else:
|
|
self.status_var.set("No readers found. Check the ACR39U USB connection.")
|
|
if warn:
|
|
messagebox.showwarning(
|
|
"No Readers Found",
|
|
"No smart card readers found.\n\nConnect the ACR39U and click Refresh.",
|
|
parent=self.root,
|
|
)
|
|
|
|
def read_card(self):
|
|
if self._reading:
|
|
return
|
|
reader_name = self.reader_var.get().strip()
|
|
if not reader_name:
|
|
messagebox.showerror("Error", "Please select a reader first.", parent=self.root)
|
|
return
|
|
|
|
self._reading = True
|
|
self.status_var.set("Reading MyKad... keep the card still.")
|
|
self.root.update_idletasks()
|
|
|
|
def worker():
|
|
try:
|
|
data = read_mykad_from_reader(reader_name=reader_name)
|
|
self.root.after(0, lambda result=data: self._apply_result(result))
|
|
except MyKadError as exc:
|
|
message = str(exc)
|
|
self.root.after(0, lambda msg=message: self._apply_error(msg))
|
|
except Exception as exc:
|
|
message = str(exc)
|
|
self.root.after(0, lambda msg=message: self._apply_error(msg))
|
|
|
|
threading.Thread(target=worker, daemon=True).start()
|
|
|
|
def _apply_result(self, data: MyKadData):
|
|
self._reading = False
|
|
self.current_data = data
|
|
values = data.as_dict()
|
|
for _, key in FIELDS:
|
|
value = values.get(key)
|
|
self.field_vars[key].set("" if value is None else str(value))
|
|
self._show_photo(data.photo_jpeg)
|
|
extra = ""
|
|
if data.photo_jpeg and self._pil_imagetk is None:
|
|
extra = " Photo is in memory; install Pillow to preview."
|
|
self.status_var.set(f"Read OK — {data.nric or 'no NRIC'}." + extra)
|
|
|
|
def _apply_error(self, message: str):
|
|
self._reading = False
|
|
self.status_var.set(message)
|
|
messagebox.showerror("Read failed", message, parent=self.root)
|
|
|
|
def _show_photo(self, jpeg_bytes: bytes):
|
|
if not jpeg_bytes:
|
|
self._photo_image = None
|
|
self.photo_label.configure(image="", text="No photo")
|
|
return
|
|
|
|
if self._pil_image is None or self._pil_imagetk is None:
|
|
self._pil_image, self._pil_imagetk = _load_pillow()
|
|
|
|
if self._pil_image is None or self._pil_imagetk is None:
|
|
self._photo_image = None
|
|
self.photo_label.configure(image="", text="Photo read\n(install Pillow\nto preview)")
|
|
return
|
|
|
|
image = self._pil_image.open(io.BytesIO(jpeg_bytes))
|
|
image.thumbnail((220, 280))
|
|
self._photo_image = self._pil_imagetk.PhotoImage(image)
|
|
self.photo_label.configure(image=self._photo_image, text="")
|
|
|
|
def save_photo(self):
|
|
if not self.current_data or not self.current_data.photo_jpeg:
|
|
messagebox.showinfo(
|
|
"Save Photo", "Read a MyKad with a photo first.", parent=self.root
|
|
)
|
|
return
|
|
path = filedialog.asksaveasfilename(
|
|
parent=self.root,
|
|
title="Save MyKad photo",
|
|
defaultextension=".jpg",
|
|
filetypes=[("JPEG", "*.jpg"), ("All files", "*.*")],
|
|
initialfile="photo.jpg",
|
|
)
|
|
if not path:
|
|
return
|
|
try:
|
|
saved = self.current_data.save_photo(path)
|
|
except MyKadError as exc:
|
|
messagebox.showerror("Save Photo", str(exc), parent=self.root)
|
|
return
|
|
self.status_var.set(f"Photo saved: {saved}")
|
|
|
|
|
|
def main():
|
|
root = tk.Tk()
|
|
MyKadReaderUI(root)
|
|
root.mainloop()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
import tkinter as _tk # noqa: F401
|
|
except ImportError:
|
|
system = platform.system()
|
|
if system == "Darwin":
|
|
print("tkinter is missing. Install Python from python.org or: brew install python-tk")
|
|
elif system == "Linux":
|
|
print("tkinter is missing. Install it with: sudo apt-get install python3-tk")
|
|
else:
|
|
print("tkinter is missing. Reinstall Python and enable tcl/tk.")
|
|
if sys.stdout.isatty():
|
|
input("Press Enter to exit...")
|
|
sys.exit(1)
|
|
main()
|