How to Turn Smart-Card Reader Output into Host-Friendly Text

Ambimat card: How to Turn Smart-Card Reader Output into Host-Friendly Text — hex, ASCII and safe logging for card-reader data

Card readers do not produce text. They move bytes. Whether those bytes reach your application as a clean hex log, a readable status line or a string full of question marks depends on decisions made on the host: how the bytes are framed, which representation you print, and whether anything along the way tried to “decode” binary data as characters. This guide explains those decisions and gives you a small, tested JavaScript module for turning reader output into text you can log, display and convert back into the exact original bytes.

Scope. Everything here is vendor-neutral. It applies to contact and contactless readers, to card responses (APDUs) and to readers that send their own status messages. It does not describe the behaviour of any particular product; check your reader’s documentation for its exact output format.

What a reader actually sends to the host

Before choosing a text format, find out which of three broad interface styles your reader uses. They differ in who decides what the bytes mean.

Interface style What the host receives Who frames the data Typical pitfall
A smart-card API on the host (for example PC/SC, or Java’s javax.smartcardio) Complete command and response APDUs as byte arrays The API: you transmit one command and receive one complete response Printing the response buffer as a string instead of as hex
A serial port: USB CDC ACM serial emulation (/dev/ttyACM* on Linux, Usbser.sys on Windows) or a USB-to-UART bridge A stream of bytes, delivered in chunks of arbitrary size Your code, following the reader’s own protocol (line endings, length prefixes, start/end markers) Assuming one read equals one message
Keyboard emulation (USB HID keyboard) Key presses, delivered to whatever window has focus The device, which has already turned its data into key presses A HID keyboard reports key positions, not characters, so the host’s keyboard layout decides what appears; arbitrary byte values cannot be represented

If your reader is in the first or second group you are dealing with raw bytes, and the rest of this article applies directly. If it presents itself as a keyboard, the conversion has already happened on the device side, and what you see also depends on the keyboard layout configured on the host. Rely only on the output format its documentation promises.

Bytes, hex text, ASCII and UTF-8 are different things

A response such as 6F 0F 84 07 A0 00 00 00 04 10 10 50 04 54 45 53 54 90 00 is nineteen bytes. Most of them are not characters at all: they are tags, lengths, identifiers and status bytes. A few of them (54 45 53 54) happen to be the ASCII codes for TEST. Any text form you choose has to cope with both kinds.

Representation Example for 50 04 54 45 53 54 90 00 Lossless? Good for
Hex, two digits per byte 50 04 54 45 53 54 90 00 Yes Logs, bug reports, test vectors, anything a machine will read again
Escaped text (space and graphic ASCII kept, everything else as \xNN) P\x04TEST\x90\x00 Yes, if the escape character itself is escaped Human-readable logs of mixed text and binary
ASCII/UTF-8 decoding with replacement P\u0004TEST�\u0000 No: 0x90 becomes U+FFFD and is gone Only data that is guaranteed to be text
Base64 UARURVNUkAA= Yes Transport in JSON or URLs; not readable

The third row is the one that causes trouble. A UTF-8 decoder in its usual “replacement” mode does not stop at a byte sequence it cannot decode; it outputs the replacement character U+FFFD and carries on. The result looks plausible, but different invalid bytes all become the same character, so the original bytes cannot be recovered from the string. In JavaScript, new TextDecoder() and Buffer.toString('utf8') both behave this way; new TextDecoder('utf-8', { fatal: true }) throws instead. Node’s legacy 'ascii' decoding is no safer: it clears the top bit of every byte.

A small module for reader output

The module below has no dependencies and runs in Node.js and modern browsers. It covers the conversions a host application usually needs:

  • toHex / fromHex: the lossless default.
  • toEscaped / fromEscaped: readable and still lossless. The space and the graphic ASCII characters (0x20–0x7E) stay as they are, a backslash is doubled, every other byte becomes \xNN. The output never contains control characters, so it cannot move the cursor or recolour a terminal when someone views the log.
  • decodeUtf8Strict: decode only when the data really is text, and fail loudly when it is not.
  • logLine / parseLogLine: one line per exchange with an ISO 8601 UTC timestamp, a direction marker and a byte count, so a log can be replayed byte for byte.
  • LineFramer: joins serial chunks and emits complete lines ending in CR, LF or CR LF.
// reader-text.mjs — turn raw card-reader bytes into text a host can log, display and parse back.
// Ambimat Electronics, 2026. Dependency-free ES module (Node.js 18+ and modern browsers).

// Lossless: every byte becomes two hex digits. Use this for logs you may need to decode later.
export function toHex(bytes, sep = ' ') {
  return Array.from(bytes, (b) => b.toString(16).toUpperCase().padStart(2, '0')).join(sep);
}

export function fromHex(text) {
  const clean = String(text).replace(/[\s:]/g, '');
  if (clean.length % 2 !== 0 || /[^0-9a-fA-F]/.test(clean)) throw new TypeError(`not a hex string: "${text}"`);
  return Uint8Array.from(clean.match(/../g) ?? [], (h) => parseInt(h, 16));
}

// Readable AND lossless: printable ASCII (0x20-0x7E) is kept, a backslash becomes "\\",
// and every other byte becomes \xNN. fromEscaped() reverses it exactly.
export function toEscaped(bytes) {
  let s = '';
  for (const b of bytes) {
    if (b === 0x5c) s += '\\\\';
    else if (b >= 0x20 && b <= 0x7e) s += String.fromCharCode(b);
    else s += '\\x' + b.toString(16).toUpperCase().padStart(2, '0');
  }
  return s;
}

export function fromEscaped(text) {
  const out = [];
  for (let i = 0; i < text.length; i++) {
    const c = text.charCodeAt(i);
    if (c > 0x7e || c < 0x20) throw new TypeError(`unexpected character at ${i}`);
    if (c !== 0x5c) { out.push(c); continue; }
    if (text[i + 1] === '\\') { out.push(0x5c); i += 1; continue; }
    const m = /^x([0-9A-Fa-f]{2})/.exec(text.slice(i + 1));
    if (!m) throw new TypeError(`bad escape at ${i}`);
    out.push(parseInt(m[1], 16)); i += 3;
  }
  return Uint8Array.from(out);
}

// Strict UTF-8: throws instead of silently replacing invalid bytes with U+FFFD.
export function decodeUtf8Strict(bytes) {
  return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
}

// One log line: ISO-8601 UTC timestamp, direction (">" host to reader, "<" reader to host), byte count, hex.
export function logLine(direction, bytes, when = new Date()) {
  if (direction !== '>' && direction !== '<') throw new TypeError('direction must be ">" or "<"');
  return `${when.toISOString()} ${direction} [${bytes.length}] ${toHex(bytes)}`;
}

export function parseLogLine(line) {
  const m = /^(\S+) ([<>]) \[(\d+)\] ?([0-9A-F ]*)$/.exec(line);
  if (!m) throw new TypeError(`not a log line: "${line}"`);
  const bytes = fromHex(m[4]);
  if (bytes.length !== Number(m[3])) throw new TypeError('byte count does not match');
  return { time: new Date(m[1]), direction: m[2], bytes };
}

// Serial/CDC data arrives in arbitrary chunks. LineFramer joins chunks and emits complete lines
// ending in CR, LF or CR LF; an incomplete tail is kept until more bytes arrive.
export class LineFramer {
  #buf = [];
  #lastCR = false;
  push(chunk) {
    const lines = [];
    for (const b of chunk) {
      if (b === 0x0a && this.#lastCR) { this.#lastCR = false; continue; } // LF after CR: same terminator
      this.#lastCR = b === 0x0d;
      if (b === 0x0d || b === 0x0a) { lines.push(Uint8Array.from(this.#buf)); this.#buf = []; }
      else this.#buf.push(b);
    }
    return lines;
  }
  get pending() { return this.#buf.length; }
}

Example: one response, four representations

The example sends nothing to hardware. It takes a fixed SELECT command and a made-up response, prints the response in each representation, writes a two-line log and reads the bytes back from it.

// log-exchange.mjs — the same reader bytes shown four ways, then logged and read back.
import { toHex, toEscaped, decodeUtf8Strict, logLine, parseLogLine } from '../reader-text.mjs';

const command  = Uint8Array.of(0x00, 0xa4, 0x04, 0x00, 0x07, 0xa0, 0x00, 0x00, 0x00, 0x04, 0x10, 0x10, 0x00);
const response = Uint8Array.of(0x6f, 0x0f, 0x84, 0x07, 0xa0, 0x00, 0x00, 0x00, 0x04, 0x10, 0x10, 0x50, 0x04, 0x54, 0x45, 0x53, 0x54, 0x90, 0x00);

console.log('hex      :', toHex(response));
console.log('escaped  :', toEscaped(response));
console.log('lenient  :', JSON.stringify(new TextDecoder().decode(response)));
try { decodeUtf8Strict(response); } catch (e) { console.log('strict   :', e.name, '- not valid UTF-8, nothing silently replaced'); }

const t0 = new Date('2026-10-06T09:30:00.000Z'), t1 = new Date('2026-10-06T09:30:00.042Z');
const log = [logLine('>', command, t0), logLine('<', response, t1)];
console.log(log.join('\n'));
const back = parseLogLine(log[1]).bytes;
console.log('read back identical:', back.length === response.length && back.every((b, i) => b === response[i]));

Output:

hex      : 6F 0F 84 07 A0 00 00 00 04 10 10 50 04 54 45 53 54 90 00
escaped  : o\x0F\x84\x07\xA0\x00\x00\x00\x04\x10\x10P\x04TEST\x90\x00
lenient  : "o\u000f�\u0007�\u0000\u0000\u0000\u0004\u0010\u0010P\u0004TEST�\u0000"
strict   : TypeError - not valid UTF-8, nothing silently replaced
2026-10-06T09:30:00.000Z > [13] 00 A4 04 00 07 A0 00 00 00 04 10 10 00
2026-10-06T09:30:00.042Z < [19] 6F 0F 84 07 A0 00 00 00 04 10 10 50 04 54 45 53 54 90 00
read back identical: true

Notice the third line: the lenient decoder turned 0x84, 0xA0 and 0x90 into the same replacement character. Nobody reading that string could tell you the status word was 90 00. The strict decoder refuses instead, which is what you want when the data is not text.

Example: framing a serial stream

A reader on a virtual serial port might send one line per card, but the operating system delivers whatever has arrived when your read completes. A single line can be split across several reads, and one read can contain the end of one line and the start of the next. The framer keeps an incomplete tail until its terminator arrives:

// serial-lines.mjs — a text-mode reader on a virtual serial port: lines arrive split across chunks.
import { LineFramer, toEscaped } from '../reader-text.mjs';

const chunks = ['UID:04A2', '1B5C\r', '\nUID:0', '4A21B5C\r\nST', 'ATUS:OK\r\n'].map((s) => new TextEncoder().encode(s));
const framer = new LineFramer();
for (const chunk of chunks) {
  for (const line of framer.push(chunk)) console.log('line:', toEscaped(line));
}
console.log('bytes still waiting for a terminator:', framer.pending);

Output:

line: UID:04A21B5C
line: UID:04A21B5C
line: STATUS:OK
bytes still waiting for a terminator: 0

The two UID lines arrive in completely different chunk patterns and still come out identical. Line endings are another source of mismatches: some devices end lines with CR, some with LF and some with CR LF. The framer accepts all three and treats CR LF as a single terminator, so a CR LF device does not produce empty lines.

Line framing only works for readers that send text lines. If your reader’s protocol carries binary payloads, a 0x0D or 0x0A byte can appear inside a message. Such protocols normally use a length field or start and end markers instead; frame by those, and still log the payload as hex.

Common mistakes

  • Treating the response buffer as a C string. Card data contains 00 bytes all the time. Code that stops at the first zero byte silently truncates the response, often just before the status bytes.
  • Converting hex to a number and back. parseInt('0004', 16).toString(16) is '4': the leading zeros, and with them the byte length, are lost. Keep identifiers as byte arrays or as hex strings of fixed width.
  • Decoding with the platform default encoding. A “default” code page differs between systems, so the same bytes can print differently on two machines. Decide the representation explicitly.
  • Logging raw bytes to a terminal. Byte 0x1B is the ESC control character, and terminals treat what follows it as a control function rather than text. Raw output can garble the screen or hide lines in a log viewer. Escaped or hex output cannot.
  • Losing the direction and the time. When a session fails, the order of commands and responses and the gap between them matter as much as the bytes. Record both.
  • Reading one serial chunk as one message. Chunk boundaries are an accident of timing and buffer sizes, not part of the protocol.

How the module was tested

The test suite (node --test, shown below) checks that hex and escaped forms round-trip every possible byte value and 2,000 random byte strings, that escaped output is always printable ASCII, that a lenient decode really loses the byte 0x90 while a strict decode throws, that log lines parse back to the exact bytes and reject a wrong byte count, and that the framer produces the same lines for 500 random chunkings of the same stream.

// reader-text.test.mjs — node --test reader-text.test.mjs   (READER_IMPL=<path> tests another copy)
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { randomBytes } from 'node:crypto';
const impl = process.env.READER_IMPL ? new URL(process.env.READER_IMPL, `file://${process.cwd()}/`).href : './reader-text.mjs';
const R = await import(impl);

test('hex and escaped forms round-trip every possible byte string', () => {
  const all = Uint8Array.from({ length: 256 }, (_, i) => i);
  for (const b of [all, new Uint8Array(0), ...Array.from({ length: 2000 }, () => randomBytes(Math.floor(Math.random() * 64)))]) {
    assert.deepEqual(R.fromHex(R.toHex(b)), Uint8Array.from(b));
    assert.deepEqual(R.fromEscaped(R.toEscaped(b)), Uint8Array.from(b));
    assert.match(R.toEscaped(b), /^[\x20-\x7e]*$/);       // output is always printable ASCII
  }
});

test('escaping keeps readable text readable', () => {
  assert.equal(R.toEscaped(new TextEncoder().encode('OK\r\n')), 'OK\\x0D\\x0A');
  assert.equal(R.toEscaped(Uint8Array.of(0x90, 0x00)), '\\x90\\x00');
  assert.equal(R.toEscaped(new TextEncoder().encode('a\\b')), 'a\\\\b');
});

test('lenient UTF-8 decoding loses data; strict decoding refuses', () => {
  const sw = Uint8Array.of(0x90, 0x00);                      // a status word is not text
  const lenient = new TextDecoder().decode(sw);
  assert.equal(lenient, '�\u0000');                    // 0x90 became U+FFFD: the original byte is gone
  assert.notDeepEqual(new TextEncoder().encode(lenient), sw);
  assert.throws(() => R.decodeUtf8Strict(sw), TypeError);
  assert.equal(R.decodeUtf8Strict(new TextEncoder().encode('Déjà')), 'Déjà');
});

test('log lines carry time, direction and exact bytes', () => {
  const t = new Date('2026-10-06T10:00:00.000Z');
  const line = R.logLine('<', Uint8Array.of(0x6a, 0x82), t);
  assert.equal(line, '2026-10-06T10:00:00.000Z < [2] 6A 82');
  assert.deepEqual(R.parseLogLine(line), { time: t, direction: '<', bytes: Uint8Array.of(0x6a, 0x82) });
  assert.equal(R.parseLogLine(R.logLine('>', new Uint8Array(0), t)).bytes.length, 0);
  assert.throws(() => R.parseLogLine('2026-10-06T10:00:00.000Z < [3] 6A 82'), TypeError);
  assert.throws(() => R.logLine('?', Uint8Array.of(1)), TypeError);
});

test('line framing is independent of how the stream is chunked', () => {
  const stream = new TextEncoder().encode('CARD 1\r\nCARD 2\nCARD 3\rCARD 4\r\n\r\ntail');
  const want = ['CARD 1', 'CARD 2', 'CARD 3', 'CARD 4', ''];
  for (let trial = 0; trial < 500; trial++) {
    const f = new R.LineFramer(); const got = [];
    for (let i = 0; i < stream.length;) {
      const n = 1 + Math.floor(Math.random() * 6);
      got.push(...f.push(stream.subarray(i, i + n)).map((l) => new TextDecoder().decode(l))); i += n;
    }
    assert.deepEqual(got, want); assert.equal(f.pending, 4);
  }
});

test('bad input is rejected', () => {
  assert.throws(() => R.fromHex('ABC'), TypeError);
  assert.throws(() => R.fromHex('ZZ'), TypeError);
  assert.throws(() => R.fromEscaped('\\q'), TypeError);
  assert.throws(() => R.fromEscaped('é'), TypeError);
});

Frequently asked questions

Should I log card data at all?

Log what you need to debug, and no more. Payment and identity cards carry personal and sensitive data. Mask or omit such fields in production logs and follow the data-protection rules that apply to your application.

Why hex and not Base64 in logs?

Both are lossless, but hex keeps byte boundaries visible: you can point at the fifth byte or spot 90 00 at the end. Base64 is more compact and suits transport, not reading.

My reader types its output like a keyboard. Can I get the raw bytes?

Not through the keyboard interface. The firmware has already chosen the characters. Some readers offer a second mode or a configuration tool; check the manufacturer’s documentation.

What is the Python equivalent?

data.hex(' ') and bytes.fromhex(text) give the same lossless hex form. data.decode('utf-8') raises an error on invalid bytes by default; errors='replace' loses data exactly like the lenient decoder above, while errors='surrogateescape' keeps each undecodable byte so that encoding the string again with the same handler gives back the original bytes.

Does this work in the browser?

Yes. The module uses only Uint8Array, TextDecoder, Date and string methods. The tests use Node.js’s built-in test runner.

Related reading on Ambimat

Sources

Written and tested by the Ambimat engineering team. The module and examples were run on Node.js 25.8.2.