Skip to content

Instantly share code, notes, and snippets.

@bouroo
Last active June 15, 2025 14:21
Show Gist options
  • Save bouroo/8b34daf5b7deed57ea54819ff7aeef6e to your computer and use it in GitHub Desktop.
Save bouroo/8b34daf5b7deed57ea54819ff7aeef6e to your computer and use it in GitHub Desktop.
Thai National ID Card reader in python
#!/usr/bin/env python3
# Kawin Viriyaprasopsook<[email protected]>
# 2025-06-15
# sudo apt-get -y install pcscd python-pyscard python-pil
from dataclasses import dataclass, field
from pathlib import Path
from smartcard.System import readers
from smartcard.util import toHexString
import sys
from typing import Callable, List, Optional, Tuple
# Common APDU constants
APDU_SELECT_COMMAND = [0x00, 0xA4, 0x04, 0x00, 0x08]
APDU_APPLET_ID = [0xA0, 0x00, 0x00, 0x00, 0x54, 0x48, 0x00, 0x01]
SW_SUCCESS = [0x90, 0x00]
# Decoder function
def thai2unicode(data: List[int]) -> str:
"""Decodes TIS-620 bytes to Unicode, replacing '#' with spaces and stripping whitespace."""
return (
bytes(data)
.decode('tis-620', errors='replace')
.replace('#', ' ')
.strip()
)
@dataclass(frozen=True)
class APDUCommand:
"""Represents an APDU command for reading a specific field from the card."""
ins: List[int]
label: str
decoder: Callable[[List[int]], str] = thai2unicode
class SmartCardError(Exception):
"""Custom exception for smart card related errors."""
pass
class SmartCardConnection:
"""Manages the low-level connection and communication with the smart card."""
def __init__(self, connection):
self.conn = connection
self.get_response_apdu_prefix: List[int] = [] # Prefix for GET RESPONSE command
def connect(self) -> None:
"""Establishes connection to the card and determines the GET RESPONSE prefix."""
self.conn.connect()
atr = self.conn.getATR()
print("ATR:", toHexString(atr))
# Determine GET RESPONSE command based on ATR (e.g., for specific card types)
# 0x3B 0x67 is a common prefix for some Thai ID cards
self.get_response_apdu_prefix = [0x00, 0xC0, 0x00, 0x01] if atr[:2] == [0x3B, 0x67] else [0x00, 0xC0, 0x00, 0x00]
def transmit(self, apdu: List[int]) -> Tuple[List[int], int, int]:
"""Transmits an APDU command and returns (data, sw1, sw2)."""
return self.conn.transmit(apdu)
class SmartCard:
"""High-level interface for interacting with a Thai National ID card."""
def __init__(self, connection: SmartCardConnection):
self.conn = connection
def initialize(self) -> None:
"""Selects the Thai ID card applet."""
apdu = APDU_SELECT_COMMAND + APDU_APPLET_ID
_, sw1, sw2 = self.conn.transmit(apdu)
if [sw1, sw2] != SW_SUCCESS:
raise SmartCardError(f"Failed to select applet: {sw1:02X} {sw2:02X}")
print(f"Select Applet: {sw1:02X} {sw2:02X}")
def _get_data_with_get_response(self, command_apdu: List[int]) -> List[int]:
"""
Sends an APDU command and then issues a GET RESPONSE command to retrieve data.
This is common for commands that return SW1/SW2 first, indicating data is available.
"""
# Send the initial command
_, sw1, sw2 = self.conn.transmit(command_apdu)
if [sw1, sw2] != SW_SUCCESS:
raise SmartCardError(f"Command failed ({toHexString(command_apdu)}): {sw1:02X} {sw2:02X}")
# Request the actual data using GET RESPONSE
# The Le byte (last byte of the original command) indicates the expected length
get_response_apdu = self.conn.get_response_apdu_prefix + [command_apdu[-1]]
data, sw1, sw2 = self.conn.transmit(get_response_apdu)
if [sw1, sw2] != SW_SUCCESS:
raise SmartCardError(f"GET RESPONSE failed ({toHexString(get_response_apdu)}): {sw1:02X} {sw2:02X}")
return data
def read_field(self, cmd: APDUCommand) -> str:
"""Reads a specific field from the card using the provided APDUCommand."""
data = self._get_data_with_get_response(cmd.ins)
result = cmd.decoder(data)
print(f"{cmd.label}: {result}")
return result
def read_photo(self, cid: str, segments: int = 20) -> None:
"""Reads the photo from the card and saves it as a JPEG file."""
# Base command for reading photo segments. P2 (cmd[4]) is the segment index.
# Le (cmd[-1]) is the expected length of each segment.
base_photo_cmd = [0x80, 0xB0, 0x00, 0x78, 0x00, 0x00, 0xFF]
photo_data = bytearray()
for i in range(1, segments + 1):
current_cmd = base_photo_cmd.copy()
current_cmd[4] = i # Set P2 to the current segment index
try:
segment_data = self._get_data_with_get_response(current_cmd)
photo_data.extend(segment_data)
except SmartCardError as e:
print(f"Warning: Could not read photo segment {i}: {e}")
# Continue to next segment, or break if critical
break # Break if a segment fails, as subsequent ones might also fail
if photo_data:
filename = Path(f"{cid}.jpg")
try:
filename.write_bytes(photo_data)
print(f"Photo saved as {filename}")
except IOError as e:
print(f"Error saving photo to {filename}: {e}")
else:
print("No photo data retrieved.")
def select_reader() -> Optional[object]:
"""Prompts the user to select a smartcard reader."""
rlist = readers()
if not rlist:
print("No smartcard readers found.")
return None
print("Available readers:")
for i, r in enumerate(rlist):
print(f" [{i}] {r}")
try:
choice_str = input("Select reader [0]: ")
choice = int(choice_str) if choice_str else 0
except ValueError:
print("Invalid input. Defaulting to reader 0.")
choice = 0
if not (0 <= choice < len(rlist)):
print(f"Invalid reader choice '{choice}'. Selecting default reader 0.")
choice = 0
return rlist[choice]
def main():
reader = select_reader()
if reader is None:
sys.exit(1)
conn = None # Initialize conn to None for finally block
try:
conn = SmartCardConnection(reader.createConnection())
conn.connect() # Establish connection and determine GET RESPONSE prefix
card = SmartCard(conn)
card.initialize() # Select the applet
commands = [
APDUCommand([0x80, 0xB0, 0x00, 0x04, 0x02, 0x00, 0x0D], "CID"),
APDUCommand([0x80, 0xB0, 0x00, 0x11, 0x02, 0x00, 0x64], "TH Fullname"),
APDUCommand([0x80, 0xB0, 0x00, 0x75, 0x02, 0x00, 0x64], "EN Fullname"),
APDUCommand([0x80, 0xB0, 0x00, 0xD9, 0x02, 0x00, 0x08], "Date of birth"),
APDUCommand([0x80, 0xB0, 0x00, 0xE1, 0x02, 0x00, 0x01], "Gender"),
APDUCommand([0x80, 0xB0, 0x00, 0xF6, 0x02, 0x00, 0x64], "Card Issuer"),
APDUCommand([0x80, 0xB0, 0x01, 0x67, 0x02, 0x00, 0x08], "Issue Date"),
APDUCommand([0x80, 0xB0, 0x01, 0x6F, 0x02, 0x00, 0x08], "Expire Date"),
APDUCommand([0x80, 0xB0, 0x15, 0x79, 0x02, 0x00, 0x64], "Address"),
]
cid = ""
for cmd in commands:
try:
result = card.read_field(cmd)
if cmd.label == "CID":
cid = result
except SmartCardError as e:
print(f"Error reading {cmd.label}: {e}")
except Exception as e:
print(f"Unexpected error reading {cmd.label}: {e}")
if cid:
card.read_photo(cid)
else:
print("CID not found; skipping photo extraction.")
except SmartCardError as e:
print(f"Smart Card Error: {e}")
sys.exit(1)
except Exception as e:
print(f"An unexpected error occurred: {e}")
sys.exit(1)
finally:
# Disconnect from the card if connection was established
if conn and conn.conn:
try:
conn.conn.disconnect()
print("Disconnected from smart card.")
except Exception as e:
print(f"Error during disconnect: {e}")
if __name__ == "__main__":
main()
@icodeforlove
Copy link

icodeforlove commented Oct 9, 2021

For python3 you can just do the following to get the photo writing correctly

Update the thai2unicode method

def thai2unicode(data):
	if isinstance(data, list):
		return bytes(data).decode('tis-620').strip().replace('#', ' ')
	else :
		return data

Replace the line with HexListToBinString with the following

data = bytes(photo)

@bouroo Thanks for this, it has been very helpful.

@pstudiodev1
Copy link

Thanks for your code.
I tried this on python3. it doesn't work on reading profile picture.
I changed some code of you then it works.

My code
https://github.com/pstudiodev1/lab-python3-th-idcard

Thanks

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment