Skip to content

Instantly share code, notes, and snippets.

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

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

Select an option

Save bouroo/ca8a41ac876f713fa8c64cb37c7b36a9 to your computer and use it in GitHub Desktop.
Thai National ID Card reader in TypeScript with Bun runtime
/**
* Thai National ID Card Reader
* Optimized for Bun Runtime with TypeScript
*
* @author Kawin Viriyaprasopsook <kawin.vir@zercle.tech>
* @refactored for TypeScript + Bun
*/
import { Devices, Card, Device } from 'smartcard';
// ============================================================================
// Types
// ============================================================================
type Command = readonly number[];
type PhotoChunk = { idx: number; chunk: string };
interface CardData {
cid: string;
thFullname: string;
enFullname: string;
dateOfBirth: string;
gender: string;
issuer: string;
issueDate: string;
expireDate: string;
address: string;
photo?: string;
}
interface DataField {
readonly cmd: Command;
readonly label: string;
readonly key: keyof CardData;
}
interface CardInsertedEvent {
card: Card;
device: Device;
}
interface DeviceActivatedEvent {
device: Device;
devices: readonly Device[];
}
interface DeviceDeactivatedEvent {
device: Device;
devices: readonly Device[];
}
// ============================================================================
// Constants
// ============================================================================
const DEFAULT_REQ: Command = [0x00, 0xC0, 0x00, 0x00];
const ALT_REQ: Command = [0x00, 0xC0, 0x00, 0x01];
const PHOTO_CHUNKS = 15;
const COMMANDS = {
SELECT_APP: [0x00, 0xA4, 0x04, 0x00, 0x08, 0xA0, 0x00, 0x00, 0x00, 0x54, 0x48, 0x00, 0x01] as const,
CID: [0x80, 0xB0, 0x00, 0x04, 0x02, 0x00, 0x0D] as const,
TH_NAME: [0x80, 0xB0, 0x00, 0x11, 0x02, 0x00, 0x64] as const,
EN_NAME: [0x80, 0xB0, 0x00, 0x75, 0x02, 0x00, 0x64] as const,
DOB: [0x80, 0xB0, 0x00, 0xD9, 0x02, 0x00, 0x08] as const,
GENDER: [0x80, 0xB0, 0x00, 0xE1, 0x02, 0x00, 0x01] as const,
ISSUER: [0x80, 0xB0, 0x00, 0xF6, 0x02, 0x00, 0x64] as const,
ISSUE_DATE: [0x80, 0xB0, 0x01, 0x67, 0x02, 0x00, 0x08] as const,
EXPIRY_DATE: [0x80, 0xB0, 0x01, 0x6F, 0x02, 0x00, 0x08] as const,
ADDRESS: [0x80, 0xB0, 0x15, 0x79, 0x02, 0x00, 0x64] as const,
PHOTO_CHUNK: (idx: number): Command => [0x80, 0xB0, idx + 1, 0x7B - idx, 0x02, 0x00, 0xFF] as const,
} as const;
const DATA_FIELDS: readonly DataField[] = [
{ cmd: COMMANDS.CID, label: 'CID', key: 'cid' },
{ cmd: COMMANDS.TH_NAME, label: 'Thai Name', key: 'thFullname' },
{ cmd: COMMANDS.EN_NAME, label: 'English Name', key: 'enFullname' },
{ cmd: COMMANDS.DOB, label: 'Date of Birth', key: 'dateOfBirth' },
{ cmd: COMMANDS.GENDER, label: 'Gender', key: 'gender' },
{ cmd: COMMANDS.ISSUER, label: 'Issuer', key: 'issuer' },
{ cmd: COMMANDS.ISSUE_DATE, label: 'Issue Date', key: 'issueDate' },
{ cmd: COMMANDS.EXPIRY_DATE, label: 'Expiry Date', key: 'expireDate' },
{ cmd: COMMANDS.ADDRESS, label: 'Address', key: 'address' },
] as const;
// ============================================================================
// Utilities
// ============================================================================
class TextDecoderISO8859_11 {
private static readonly decodeTable = new TextDecoder('iso-8859-11');
static decode(buffer: Uint8Array): string {
return this.decodeTable.decode(buffer);
}
}
function hexToBase64(hexString: string): string {
const bytes = new Uint8Array(
hexString.match(/.{1,2}/g)?.map(byte => parseInt(byte, 16)) ?? []
);
return btoa(String.fromCharCode(...bytes));
}
// ============================================================================
// Card Session
// ============================================================================
class CardSession {
private readonly reqHeader: Command;
private readonly decoder = TextDecoderISO8859_11;
constructor(private readonly card: Card) {
const atr = this.card.getAtr();
this.reqHeader = atr.startsWith('3b67') ? ALT_REQ : DEFAULT_REQ;
}
async run(): Promise<CardData> {
await this.issueCommand(COMMANDS.SELECT_APP);
const cardData: Partial<CardData> = {};
// Fetch all data fields in parallel
const fieldPromises = DATA_FIELDS.map(async ({ cmd, label, key }) => {
const data = await this.fetch(cmd);
const decoded = this.decoder.decode(data.slice(0, -2));
return { key, value: decoded, label };
});
const results = await Promise.all(fieldPromises);
results.forEach(({ key, value, label }) => {
cardData[key] = value;
console.log(`${label}: ${value}`);
});
// Fetch photo
cardData.photo = await this.fetchPhoto();
return cardData as CardData;
}
private async fetch(cmd: Command): Promise<Uint8Array> {
await this.issueCommand(cmd);
const le = cmd[cmd.length - 1];
return this.issueCommand([...this.reqHeader, le]);
}
private async fetchPhoto(): Promise<string> {
const promises = Array.from(
{ length: PHOTO_CHUNKS },
(_, i) => this.fetchChunk(i)
);
const chunks = await Promise.all(promises);
const hexPhoto = chunks
.sort((a, b) => a.idx - b.idx)
.map(p => p.chunk)
.join('');
const base64Photo = `data:image/jpg;base64,${hexToBase64(hexPhoto)}`;
console.log(`Photo: ${base64Photo.substring(0, 50)}...`);
return base64Photo;
}
private async fetchChunk(idx: number): Promise<PhotoChunk> {
const buf = await this.fetch(COMMANDS.PHOTO_CHUNK(idx));
return {
idx,
chunk: Buffer.from(buf).toString('hex').slice(0, -4),
};
}
private async issueCommand(apdu: Command): Promise<Uint8Array> {
return this.card.issueCommand(apdu);
}
}
// ============================================================================
// Device Manager
// ============================================================================
class DeviceManager {
private readonly devices: Devices;
constructor(
private readonly onCardRead?: (data: CardData) => void | Promise<void>,
private readonly onError?: (error: Error) => void
) {
this.devices = new Devices();
this.bindEvents();
}
private bindEvents(): void {
this.devices.on('device-activated', this.handleDeviceActivated.bind(this));
this.devices.on('device-deactivated', this.handleDeviceDeactivated.bind(this));
}
private handleDeviceActivated({ device, devices }: DeviceActivatedEvent): void {
console.log(`Device '${device}' activated. Total devices: ${devices.length}`);
device.on('card-inserted', this.handleCardInserted.bind(this));
device.on('card-removed', this.handleCardRemoved.bind(this));
}
private async handleCardInserted({ card, device }: CardInsertedEvent): Promise<void> {
const atr = card.getAtr();
console.log(`Card inserted (ATR: ${atr}) into device '${device}'`);
try {
const session = new CardSession(card);
const cardData = await session.run();
console.log('✓ Card read successfully');
if (this.onCardRead) {
await this.onCardRead(cardData);
}
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
console.error('✗ Failed to read card:', err.message);
if (this.onError) {
this.onError(err);
}
}
}
private handleCardRemoved({ name }: { name: string }): void {
console.log(`Card removed from device '${name}'`);
}
private handleDeviceDeactivated({ device, devices }: DeviceDeactivatedEvent): void {
console.log(`Device '${device}' deactivated. Remaining devices: ${devices.length}`);
}
}
// ============================================================================
// Example Usage / Entry Point
// ============================================================================
export { CardData, CardSession, DeviceManager };
// If running as main script
if (import.meta.main) {
const manager = new DeviceManager(
async (data) => {
console.log('\n📋 Card Data Summary:');
console.log(JSON.stringify(data, null, 2));
},
(error) => {
console.error('Card reader error:', error);
}
);
// Graceful shutdown
const shutdown = () => {
console.log('\nShutting down...');
process.exit(0);
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}
export default DeviceManager;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment