-
-
Save bouroo/8b34daf5b7deed57ea54819ff7aeef6e to your computer and use it in GitHub Desktop.
| #!/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()) |
สอบถามหน่อยครับ
ที่ 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
มีคำแนะนำมั้ยครับ
ขอบคุณครับ
รบกวนสอบถามหน่อยครับ
i have problem about select photo from thai national card by use python3.6 language. My code is below.
Photo=[]
#PhotoPhoto_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+dataPhoto 2
data, sw1, sw2 = connection.transmit(CMD_PHOTO2)
data, sw1, sw2 = connection.transmit(CMD_PHOTOREQ)
Photo=Photo+dataPhoto 3
data, sw1, sw2 = connection.transmit(CMD_PHOTO3)
data, sw1, sw2 = connection.transmit(CMD_PHOTOREQ)
Photo=Photo+dataPhoto 4
data, sw1, sw2 = connection.transmit(CMD_PHOTO4)
data, sw1, sw2 = connection.transmit(CMD_PHOTOREQ)
Photo=Photo+dataPhoto 5
data, sw1, sw2 = connection.transmit(CMD_PHOTO5)
data, sw1, sw2 = connection.transmit(CMD_PHOTOREQ)
Photo=Photo+dataPhoto 6
data, sw1, sw2 = connection.transmit(CMD_PHOTO6)
data, sw1, sw2 = connection.transmit(CMD_PHOTOREQ)
Photo=Photo+dataPhoto 7
data, sw1, sw2 = connection.transmit(CMD_PHOTO7)
data, sw1, sw2 = connection.transmit(CMD_PHOTOREQ)
Photo=Photo+dataPhoto 8
data, sw1, sw2 = connection.transmit(CMD_PHOTO8)
data, sw1, sw2 = connection.transmit(CMD_PHOTOREQ)
Photo=Photo+dataPhoto 9
data, sw1, sw2 = connection.transmit(CMD_PHOTO9)
data, sw1, sw2 = connection.transmit(CMD_PHOTOREQ)
Photo=Photo+dataPhoto 10
data, sw1, sw2 = connection.transmit(CMD_PHOTO10)
data, sw1, sw2 = connection.transmit(CMD_PHOTOREQ)
Photo=Photo+dataPhoto 11
data, sw1, sw2 = connection.transmit(CMD_PHOTO11)
data, sw1, sw2 = connection.transmit(CMD_PHOTOREQ)
Photo=Photo+dataPhoto 12
data, sw1, sw2 = connection.transmit(CMD_PHOTO12)
data, sw1, sw2 = connection.transmit(CMD_PHOTOREQ)
Photo=Photo+dataPhoto 13
data, sw1, sw2 = connection.transmit(CMD_PHOTO13)
data, sw1, sw2 = connection.transmit(CMD_PHOTOREQ)
Photo=Photo+dataPhoto 14
data, sw1, sw2 = connection.transmit(CMD_PHOTO14)
data, sw1, sw2 = connection.transmit(CMD_PHOTOREQ)
Photo=Photo+dataPhoto 15
data, sw1, sw2 = connection.transmit(CMD_PHOTO15)
data, sw1, sw2 = connection.transmit(CMD_PHOTOREQ)
Photo=Photo+dataPhoto 16
data, sw1, sw2 = connection.transmit(CMD_PHOTO16)
data, sw1, sw2 = connection.transmit(CMD_PHOTOREQ)
Photo=Photo+dataPhoto 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+dataPhoto 19
data, sw1, sw2 = connection.transmit(CMD_PHOTO19)
data, sw1, sw2 = connection.transmit(CMD_PHOTOREQ)
Photo=Photo+dataPhoto 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?
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
@bossbojo Currently No, Now have only pyhton and GO, but I'll looking for nodejs ASAP.