============================================================================

January 7, 2026 ยท View on GitHub

#!/usr/bin/env python3 """Thai National ID Card Reader - Refactored for Python 3.

Author: Kawin Viriyaprasopsookkawin.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())