Last active
January 7, 2026 06:47
-
-
Save bouroo/8b34daf5b7deed57ea54819ff7aeef6e to your computer and use it in GitHub Desktop.
Thai National ID Card reader in python
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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()) |
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 dataReplace the line with HexListToBinString with the following
data = bytes(photo)@bouroo Thanks for this, it has been very helpful.
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
I'm having the same problem with you, how to fix it?