Skip to content

Instantly share code, notes, and snippets.

@bouroo
Last active January 7, 2026 06:47
Show Gist options
  • Select an option

  • Save bouroo/8b34daf5b7deed57ea54819ff7aeef6e to your computer and use it in GitHub Desktop.

Select an option

Save bouroo/8b34daf5b7deed57ea54819ff7aeef6e to your computer and use it in GitHub Desktop.
Thai National ID Card reader in python
#!/usr/bin/env python3
"""Thai National ID Card Reader - Refactored for Python 3.
Author: Kawin Viriyaprasopsook<kawin.v@kkumail.com>
Date: 2025-06-15
Requirements: sudo apt-get -y install pcscd python3-pyscard python3-pil
Refactoring improvements:
- Context manager support for automatic resource cleanup
- Enum for status words and constants
- Logging instead of print statements
- Type hints with forward references
- Memory optimization with __slots__
- Better error handling
- Configuration separation
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from enum import IntEnum
from pathlib import Path
from typing import Callable, Optional
from smartcard.System import readers
from smartcard.util import toHexString
# ============================================================================
# Configuration & Constants
# ============================================================================
LOGGING_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
DEFAULT_PHOTO_SEGMENTS = 20
class StatusCode(IntEnum):
"""ISO 7816 Status Words."""
SUCCESS = 0x9000
MORE_DATA_AVAILABLE = 0x6100
class APDU:
"""APDU command constants."""
SELECT_COMMAND = bytes([0x00, 0xA4, 0x04, 0x00, 0x08])
APPLET_ID = bytes([0xA0, 0x00, 0x00, 0x00, 0x54, 0x48, 0x00, 0x01])
BASE_PHOTO_CMD = bytes([0x80, 0xB0, 0x00, 0x78, 0x00, 0x00, 0xFF])
GET_RESPONSE_STANDARD = bytes([0x00, 0xC0, 0x00, 0x00])
GET_RESPONSE_THAI_CARD = bytes([0x00, 0xC0, 0x00, 0x01])
# Configure logging
logging.basicConfig(
format=LOGGING_FORMAT,
level=logging.INFO,
handlers=[logging.StreamHandler()]
)
logger = logging.getLogger(__name__)
# ============================================================================
# Type Definitions
# ============================================================================
DecoderType = Callable[[bytes], str]
CardData = tuple[bytes, int, int]
# ============================================================================
# Custom Exceptions
# ============================================================================
class SmartCardError(Exception):
"""Base exception for smart card related errors."""
pass
class NoReaderError(SmartCardError):
"""Raised when no smart card readers are available."""
pass
class APDUCommandError(SmartCardError):
"""Raised when an APDU command fails."""
pass
# ============================================================================
# Data Classes
# ============================================================================
@dataclass(frozen=True, slots=True)
class APDUCommand:
"""Represents an APDU command for reading a specific field from the card.
Attributes:
instruction: The APDU instruction bytes
label: Human-readable field label
decoder: Function to decode raw bytes to string
"""
instruction: bytes
label: str
decoder: DecoderType = thai2unicode
# ============================================================================
# Decoder Functions
# ============================================================================
def thai2unicode(data: bytes) -> str:
"""Decodes TIS-620 bytes to Unicode string.
Args:
data: Raw bytes encoded in TIS-620
Returns:
Decoded Unicode string with '#' replaced by spaces and stripped
"""
try:
return (
data
.decode('tis-620', errors='replace')
.replace('#', ' ')
.strip()
)
except UnicodeDecodeError as e:
logger.warning(f"Failed to decode TIS-620 data: {e}")
return ""
# ============================================================================
# Smart Card Connection
# ============================================================================
class SmartCardConnection:
"""Manages low-level connection and communication with the smart card.
This class implements the context manager protocol for automatic
resource cleanup.
"""
__slots__ = ('conn', 'get_response_prefix')
def __init__(self, connection: object) -> None:
"""Initialize the connection wrapper.
Args:
connection: Raw smartcard connection object
"""
self.conn = connection
self.get_response_prefix: bytes = APDU.GET_RESPONSE_STANDARD
def connect(self) -> None:
"""Establish connection and determine GET RESPONSE prefix based on ATR."""
try:
self.conn.connect()
atr = self.conn.getATR()
logger.info(f"ATR: {toHexString(atr)}")
# Determine GET RESPONSE command based on ATR
# 0x3B 0x67 is a common prefix for Thai ID cards
self.get_response_prefix = (
APDU.GET_RESPONSE_THAI_CARD
if atr[:2] == [0x3B, 0x67]
else APDU.GET_RESPONSE_STANDARD
)
except Exception as e:
raise SmartCardError(f"Failed to connect to card: {e}") from e
def transmit(self, apdu: bytes) -> CardData:
"""Transmit an APDU command.
Args:
apdu: APDU command as bytes
Returns:
Tuple of (response_data, sw1, sw2)
"""
return self.conn.transmit(list(apdu))
def disconnect(self) -> None:
"""Disconnect from the card."""
try:
if self.conn:
self.conn.disconnect()
logger.info("Disconnected from smart card")
except Exception as e:
logger.warning(f"Error during disconnect: {e}")
def __enter__(self) -> SmartCardConnection:
"""Context manager entry."""
self.connect()
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
"""Context manager exit."""
self.disconnect()
# ============================================================================
# Smart Card Interface
# ============================================================================
class SmartCard:
"""High-level interface for interacting with a Thai National ID card.
Provides methods to read personal data fields and photos from the card.
"""
__slots__ = ('conn',)
def __init__(self, connection: SmartCardConnection) -> None:
"""Initialize the smart card interface.
Args:
connection: Active SmartCardConnection instance
"""
self.conn = connection
def initialize(self) -> None:
"""Select the Thai ID card applet.
Raises:
APDUCommandError: If applet selection fails
"""
apdu = APDU.SELECT_COMMAND + APDU.APPLET_ID
_, sw1, sw2 = self.conn.transmit(apdu)
status_word = (sw1 << 8) | sw2
if status_word != StatusCode.SUCCESS:
raise APDUCommandError(
f"Failed to select applet: {sw1:02X} {sw2:02X}"
)
logger.info(f"Select Applet: {sw1:02X} {sw2:02X}")
def _get_data_with_get_response(self, command_apdu: bytes) -> bytes:
"""Send APDU command and retrieve data using GET RESPONSE.
Args:
command_apdu: The initial APDU command bytes
Returns:
Response data as bytes
Raises:
APDUCommandError: If command or GET RESPONSE fails
"""
# Send the initial command
_, sw1, sw2 = self.conn.transmit(command_apdu)
status_word = (sw1 << 8) | sw2
if status_word != StatusCode.SUCCESS:
raise APDUCommandError(
f"Command failed ({toHexString(list(command_apdu))}): "
f"{sw1:02X} {sw2:02X}"
)
# Request the actual data using GET RESPONSE
# Le byte (last byte of original command) indicates expected length
get_response_apdu = self.conn.get_response_prefix + bytes([command_apdu[-1]])
data, sw1, sw2 = self.conn.transmit(get_response_apdu)
status_word = (sw1 << 8) | sw2
if status_word != StatusCode.SUCCESS:
raise APDUCommandError(
f"GET RESPONSE failed ({toHexString(list(get_response_apdu))}): "
f"{sw1:02X} {sw2:02X}"
)
return bytes(data)
def read_field(self, cmd: APDUCommand) -> str:
"""Read a specific field from the card.
Args:
cmd: APDUCommand describing the field to read
Returns:
Decoded field value as string
"""
data = self._get_data_with_get_response(cmd.instruction)
result = cmd.decoder(data)
logger.info(f"{cmd.label}: {result}")
return result
def read_photo(self, cid: str, segments: int = DEFAULT_PHOTO_SEGMENTS) -> Optional[Path]:
"""Read and save the photo from the card.
Args:
cid: Citizen ID number for filename
segments: Number of photo segments to read
Returns:
Path to saved photo file, or None if failed
"""
photo_data = bytearray()
base_photo_cmd = bytearray(APDU.BASE_PHOTO_CMD)
for segment in range(1, segments + 1):
current_cmd = base_photo_cmd.copy()
current_cmd[4] = segment # Set P2 to current segment index
try:
segment_data = self._get_data_with_get_response(bytes(current_cmd))
photo_data.extend(segment_data)
except APDUCommandError as e:
logger.warning(f"Could not read photo segment {segment}: {e}")
break
if not photo_data:
logger.warning("No photo data retrieved")
return None
filename = Path(f"{cid}.jpg")
try:
filename.write_bytes(photo_data)
logger.info(f"Photo saved as {filename}")
return filename
except (IOError, OSError) as e:
logger.error(f"Error saving photo to {filename}: {e}")
return None
# ============================================================================
# Reader Selection
# ============================================================================
def select_reader() -> Optional[object]:
"""Prompt user to select a smart card reader.
Returns:
Selected reader object, or None if no readers available
Raises:
NoReaderError: If no readers are found
"""
reader_list = readers()
if not reader_list:
logger.error("No smartcard readers found")
raise NoReaderError("No smartcard readers available")
print("Available readers:")
for i, reader in enumerate(reader_list):
print(f" [{i}] {reader}")
try:
choice_str = input("Select reader [0]: ").strip()
choice = int(choice_str) if choice_str else 0
except ValueError:
logger.warning("Invalid input. Defaulting to reader 0")
choice = 0
if not (0 <= choice < len(reader_list)):
logger.warning(f"Invalid choice '{choice}'. Using reader 0")
choice = 0
return reader_list[choice]
# ============================================================================
# Field Definitions
# ============================================================================
THAI_ID_FIELDS = [
APDUCommand(bytes([0x80, 0xB0, 0x00, 0x04, 0x02, 0x00, 0x0D]), "CID"),
APDUCommand(bytes([0x80, 0xB0, 0x00, 0x11, 0x02, 0x00, 0x64]), "TH Fullname"),
APDUCommand(bytes([0x80, 0xB0, 0x00, 0x75, 0x02, 0x00, 0x64]), "EN Fullname"),
APDUCommand(bytes([0x80, 0xB0, 0x00, 0xD9, 0x02, 0x00, 0x08]), "Date of birth"),
APDUCommand(bytes([0x80, 0xB0, 0x00, 0xE1, 0x02, 0x00, 0x01]), "Gender"),
APDUCommand(bytes([0x80, 0xB0, 0x00, 0xF6, 0x02, 0x00, 0x64]), "Card Issuer"),
APDUCommand(bytes([0x80, 0xB0, 0x01, 0x67, 0x02, 0x00, 0x08]), "Issue Date"),
APDUCommand(bytes([0x80, 0xB0, 0x01, 0x6F, 0x02, 0x00, 0x08]), "Expire Date"),
APDUCommand(bytes([0x80, 0xB0, 0x15, 0x79, 0x02, 0x00, 0x64]), "Address"),
]
# ============================================================================
# Main Application
# ============================================================================
def main() -> int:
"""Main application entry point.
Returns:
Exit code (0 for success, 1 for failure)
"""
try:
reader = select_reader()
except NoReaderError:
return 1
try:
connection = SmartCardConnection(reader.createConnection())
with connection:
card = SmartCard(connection)
card.initialize()
# Read all fields
cid = ""
for cmd in THAI_ID_FIELDS:
try:
result = card.read_field(cmd)
if cmd.label == "CID":
cid = result
except APDUCommandError as e:
logger.error(f"Error reading {cmd.label}: {e}")
except Exception as e:
logger.error(f"Unexpected error reading {cmd.label}: {e}")
# Read photo if CID was obtained
if cid:
card.read_photo(cid)
else:
logger.warning("CID not found; skipping photo extraction")
return 0
except SmartCardError as e:
logger.error(f"Smart Card Error: {e}")
return 1
except Exception as e:
logger.exception(f"An unexpected error occurred: {e}")
return 1
if __name__ == "__main__":
exit(main())
@bouroo
Copy link
Author

bouroo commented Jan 3, 2019

you have thai card id version NODEJS ?

@bossbojo Currently No, Now have only pyhton and GO, but I'll looking for nodejs ASAP.

@inlovet
Copy link

inlovet commented Mar 28, 2019

สอบถามหน่อยครับ

ที่ line 21 result += unicode(chr(d),"tis-620")

ลองรันแล้วเจอปัญหานี้อะครับ
NameError: name 'unicode' is not defined

พยายามหาข้อมูลเองเขาบอกให้เปลี่ยนจาก unicode เป็น str เพราะใช้ python version 3
แต่พอลองเปลี่ยนก็เจออีกปัญหาอะครับ
TypeError: decoding str is not supported

มีคำแนะนำมั้ยครับ
ขอบคุณครับ

@pattz1005
Copy link

รบกวนสอบถามหน่อยครับ

i have problem about select photo from thai national card by use python3.6 language. My code is below.

Photo=[]
#Photo

Photo_Part1/20

CMD_PHOTO1 = [0x80, 0xb0, 0x01, 0x7B, 0x02, 0x00, 0xFF]
CMD_PHOTOREQ=[0x00, 0xc0, 0x00, 0x00, 0xFF]

Photo_Part2/20

CMD_PHOTO2 = [0x80, 0xb0, 0x02, 0x7A, 0x02, 0x00, 0xFF]

Photo_Part3/20

CMD_PHOTO3 = [0x80, 0xb0, 0x03, 0x79, 0x02, 0x00, 0xFF]

Photo_Part4/20

CMD_PHOTO4 = [0x80, 0xb0, 0x04, 0x78, 0x02, 0x00, 0xFF]

Photo_Part5/20

CMD_PHOTO5 = [0x80, 0xb0, 0x05, 0x77, 0x02, 0x00, 0xFF]

Photo_Part6/20

CMD_PHOTO6 = [0x80, 0xb0, 0x06, 0x76, 0x02, 0x00, 0xFF]

Photo_Part7/20

CMD_PHOTO7 = [0x80, 0xb0, 0x07, 0x75, 0x02, 0x00, 0xFF]

Photo_Part8/20

CMD_PHOTO8 = [0x80, 0xb0, 0x08, 0x74, 0x02, 0x00, 0xFF]

Photo_Part9/20

CMD_PHOTO9 = [0x80, 0xb0, 0x09, 0x73, 0x02, 0x00, 0xFF]

Photo_Part10/20

CMD_PHOTO10 = [0x80, 0xb0, 0x0A, 0x72, 0x02, 0x00, 0xFF]

Photo_Part11/20

CMD_PHOTO11 = [0x80, 0xb0, 0x0B, 0x71, 0x02, 0x00, 0xFF]

Photo_Part12/20

CMD_PHOTO12 = [0x80, 0xb0, 0x0C, 0x70, 0x02, 0x00, 0xFF]

Photo_Part13/20

CMD_PHOTO13 = [0x80, 0xb0, 0x0D, 0x6F, 0x02, 0x00, 0xFF]

Photo_Part14/20

CMD_PHOTO14 = [0x80, 0xb0, 0x0E, 0x6E, 0x02, 0x00, 0xFF]

Photo_Part15/20

CMD_PHOTO15 = [0x80, 0xb0, 0x0F, 0x6D, 0x02, 0x00, 0xFF]

Photo_Part16/20

CMD_PHOTO16 = [0x80, 0xb0, 0x10, 0x6C, 0x02, 0x00, 0xFF]

Photo_Part17/20

CMD_PHOTO17 = [0x80, 0xb0, 0x11, 0x6B, 0x02, 0x00, 0xFF]

Photo_Part18/20

CMD_PHOTO18 = [0x80, 0xb0, 0x12, 0x6A, 0x02, 0x00, 0xFF]

Photo_Part19/20

CMD_PHOTO19 = [0x80, 0xb0, 0x13, 0x69, 0x02, 0x00, 0xFF]

Photo_Part20/20

CMD_PHOTO20 = [0x80, 0xb0, 0x14, 0x68, 0x02, 0x00, 0xFF]

Photo 1

data, sw1, sw2 = connection.transmit(CMD_PHOTO1)
data, sw1, sw2 = connection.transmit(CMD_PHOTOREQ)
Photo=Photo+data

Photo 2

data, sw1, sw2 = connection.transmit(CMD_PHOTO2)
data, sw1, sw2 = connection.transmit(CMD_PHOTOREQ)
Photo=Photo+data

Photo 3

data, sw1, sw2 = connection.transmit(CMD_PHOTO3)
data, sw1, sw2 = connection.transmit(CMD_PHOTOREQ)
Photo=Photo+data

Photo 4

data, sw1, sw2 = connection.transmit(CMD_PHOTO4)
data, sw1, sw2 = connection.transmit(CMD_PHOTOREQ)
Photo=Photo+data

Photo 5

data, sw1, sw2 = connection.transmit(CMD_PHOTO5)
data, sw1, sw2 = connection.transmit(CMD_PHOTOREQ)
Photo=Photo+data

Photo 6

data, sw1, sw2 = connection.transmit(CMD_PHOTO6)
data, sw1, sw2 = connection.transmit(CMD_PHOTOREQ)
Photo=Photo+data

Photo 7

data, sw1, sw2 = connection.transmit(CMD_PHOTO7)
data, sw1, sw2 = connection.transmit(CMD_PHOTOREQ)
Photo=Photo+data

Photo 8

data, sw1, sw2 = connection.transmit(CMD_PHOTO8)
data, sw1, sw2 = connection.transmit(CMD_PHOTOREQ)
Photo=Photo+data

Photo 9

data, sw1, sw2 = connection.transmit(CMD_PHOTO9)
data, sw1, sw2 = connection.transmit(CMD_PHOTOREQ)
Photo=Photo+data

Photo 10

data, sw1, sw2 = connection.transmit(CMD_PHOTO10)
data, sw1, sw2 = connection.transmit(CMD_PHOTOREQ)
Photo=Photo+data

Photo 11

data, sw1, sw2 = connection.transmit(CMD_PHOTO11)
data, sw1, sw2 = connection.transmit(CMD_PHOTOREQ)
Photo=Photo+data

Photo 12

data, sw1, sw2 = connection.transmit(CMD_PHOTO12)
data, sw1, sw2 = connection.transmit(CMD_PHOTOREQ)
Photo=Photo+data

Photo 13

data, sw1, sw2 = connection.transmit(CMD_PHOTO13)
data, sw1, sw2 = connection.transmit(CMD_PHOTOREQ)
Photo=Photo+data

Photo 14

data, sw1, sw2 = connection.transmit(CMD_PHOTO14)
data, sw1, sw2 = connection.transmit(CMD_PHOTOREQ)
Photo=Photo+data

Photo 15

data, sw1, sw2 = connection.transmit(CMD_PHOTO15)
data, sw1, sw2 = connection.transmit(CMD_PHOTOREQ)
Photo=Photo+data

Photo 16

data, sw1, sw2 = connection.transmit(CMD_PHOTO16)
data, sw1, sw2 = connection.transmit(CMD_PHOTOREQ)
Photo=Photo+data

Photo 17

data, sw1, sw2 = connection.transmit(CMD_PHOTO17)
data, sw1, sw2 = connection.transmit(CMD_PHOTOREQ)
Photo=Photo+data
print ("Command14: %02X %02X" % (sw1, sw2))

Photo 18

data, sw1, sw2 = connection.transmit(CMD_PHOTO18)
data, sw1, sw2 = connection.transmit(CMD_PHOTOREQ)
Photo=Photo+data

Photo 19

data, sw1, sw2 = connection.transmit(CMD_PHOTO19)
data, sw1, sw2 = connection.transmit(CMD_PHOTOREQ)
Photo=Photo+data

Photo 20

data, sw1, sw2 = connection.transmit(CMD_PHOTO20)
data, sw1, sw2 = connection.transmit(CMD_PHOTOREQ)
Photo=Photo+data
print(Photo) # Output =[255, 216, 255, 224, 0, 16, 74, 70, 73, 70, 0, 1, 1, 1, 0, 96, 0, 96, 0, 0, 255, 219, 0, 67, 0, 15, 10, 11, 13, 11, 9, 15, 13, 12, 13, 17, 16, 15, 18, 23, 38, 24, 23, 21, 21, 23, 46, 33, 35, 27, 38, 55, 48, 57, 56, 54, 48, 53, 52, 60, 68, 86, 73, 60, 64, 82, 65, 52, 53, 75, 102 ......]
print(str(len(Photo))) # Output=5100
print(type(Photo)) # <class 'list'>
Photo=HexListToBinString(Photo)
print(Photo) #Output = ÿØÿà\000�JFIF\000���\000\000\000\000ÿÛ\000C\000�

print(str(len(Photo))) #Output is 5100

f=open("cid.jpg","wb")
f.write(Photo)
f.close
When Program run to command "f.write(Photo)" will have error "builtins.TypeError: a bytes-like object is required, not 'str'"

I should fix this error ?

I'm having the same problem with you, how to fix it?

@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