Decoding Calypso Smart Card Transaction Logs

You have been handed a batch of raw Calypso event logs pulled from offline validators, and the task is exact: turn those opaque 27-byte binary frames into typed, auditable transactions your reconciliation pipeline can trust — rejecting anything with a bad checksum, an unknown event type, or a broken sequence rather than letting it drift silently into revenue. This is a decoding-and-validation job, not a pricing job: it belongs to the Smart Card Schema Mapping stage of the Core Architecture & Fare Taxonomy pipeline, where vendor-specific bytes become a stable CalypsoTransaction record. Fare amounts are never computed here; the mapper emits card identity, event type, timestamp, and fare zone, and leaves money to downstream Decimal arithmetic. This guide gives transit ops staff, revenue analysts, and Python automation builders a runnable parser and a strict reconciliation loop for exactly that step.

The frames reaching this code have already cleared cryptographic authentication upstream — the AFC System Security Boundaries verify diversified keys and session MACs before any byte is decoded. The parser below therefore trusts the payload’s authenticity and concerns itself only with structural integrity: length, CRC, and field bounds.

Binary Frame Structure & Checksum Validation

Calypso transaction logs adhere to ISO/IEC 14443 and EN 1545, structuring each event around a fixed-length header, a variable payload, and a cryptographic trailer. For the compact validator log format handled here, every record is a fixed 27-byte frame laid out as [4B TxID][8B UID][1B Type][6B MAC][4B TS][2B Zone][2B CRC]. The payload carries the card UID, the transaction type (tap-in, tap-out, transfer, top-up, penalty), the validator MAC address, a synchronized timestamp, and the fare zone identifier. Misalignment at this stage causes silent revenue leakage and downstream accounting drift.

Every frame must be validated at the ingestion boundary. Implement CRC-16/CCITT-FALSE verification before any downstream processing. Reject records with failed checksums immediately; never attempt heuristic repair on cryptographic payloads. The implementation below is a strict, type-hinted parser with explicit error handling and structured audit logging.

The flow below shows the ordered gates a single Calypso frame passes before it becomes a structured transaction:

Calypso frame validation gates A 27-byte frame flows left to right through three sequential gates. First a length gate confirms the frame is exactly 27 bytes; a short read raises CalypsoParseError. Then a CRC-16/CCITT gate rejects any checksum mismatch as ChecksumValidationError, routed to the dead-letter trail. Then a known-transaction-type gate rejects unrecognized event codes as CalypsoParseError. Only a frame passing all three has its UID, MAC, timestamp, and zone unpacked into a frozen CalypsoTransaction record. yes yes yes no no no 27-byte frame raw bytes Length == 27? CRC-16 match? Known type? Unpack UID · MAC · TS · zone Calypso Transaction CalypsoParseError bad length ChecksumValidationError reject · dead-letter CalypsoParseError unknown type
import struct
import logging
import json
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Iterator
from enum import IntEnum

# Configure structured JSON audit logger
logging.basicConfig(
    level=logging.INFO,
    format="%(message)s",
    handlers=[logging.StreamHandler()],
)
logger = logging.getLogger("calypso_reconciliation")

FRAME_LEN = 27  # [4B TxID][8B UID][1B Type][6B MAC][4B TS][2B Zone][2B CRC]


class TransactionType(IntEnum):
    TAP_IN = 0x01
    TAP_OUT = 0x02
    TRANSFER = 0x03
    TOP_UP = 0x04
    PENALTY = 0x05


class CalypsoParseError(Exception):
    """Base exception for frame parsing failures."""


class ChecksumValidationError(CalypsoParseError):
    """Raised when CRC-16/CCITT validation fails."""


class SequenceGapError(CalypsoParseError):
    """Raised when the validator sequence jumps unexpectedly."""


@dataclass(frozen=True)
class CalypsoTransaction:
    transaction_id: int
    card_uid: str
    tx_type: TransactionType
    validator_mac: str
    timestamp_utc: datetime
    fare_zone_id: int
    raw_hex: str
    audit_metadata: dict = field(default_factory=dict)


def calculate_crc16_ccitt(data: bytes, init: int = 0xFFFF) -> int:
    """Standard CRC-16/CCITT-FALSE implementation for Calypso frames."""
    crc = init
    for byte in data:
        crc ^= byte << 8
        for _ in range(8):
            crc = (crc << 1) ^ 0x1021 if crc & 0x8000 else crc << 1
    return crc & 0xFFFF


def parse_calypso_frame(frame: bytes) -> CalypsoTransaction:
    """
    Unpack a fixed-width Calypso transaction frame.
    Layout: [4B TxID][8B UID][1B Type][6B MAC][4B TS][2B Zone][2B CRC]
    """
    if len(frame) != FRAME_LEN:
        raise CalypsoParseError(
            f"Invalid frame length: {len(frame)} bytes (expected {FRAME_LEN})"
        )

    payload = frame[:-2]
    expected_crc = struct.unpack(">H", frame[-2:])[0]
    actual_crc = calculate_crc16_ccitt(payload)
    if expected_crc != actual_crc:
        raise ChecksumValidationError(
            f"CRC mismatch: expected 0x{expected_crc:04X}, got 0x{actual_crc:04X}"
        )

    tx_id = struct.unpack(">I", frame[0:4])[0]
    uid_bytes = frame[4:12]
    tx_type_raw = frame[12]
    mac_bytes = frame[13:19]
    ts_epoch = struct.unpack(">I", frame[19:23])[0]
    zone_id = struct.unpack(">H", frame[23:25])[0]

    try:
        tx_type = TransactionType(tx_type_raw)
    except ValueError as exc:
        raise CalypsoParseError(
            f"Unknown transaction type: 0x{tx_type_raw:02X}"
        ) from exc

    return CalypsoTransaction(
        transaction_id=tx_id,
        card_uid=uid_bytes.hex().upper(),
        tx_type=tx_type,
        validator_mac=":".join(f"{b:02X}" for b in mac_bytes),
        timestamp_utc=datetime.fromtimestamp(ts_epoch, tz=timezone.utc),
        fare_zone_id=zone_id,
        raw_hex=frame.hex().upper(),
        audit_metadata={"parsed_at": datetime.now(timezone.utc).isoformat()},
    )

Revenue Reconciliation & Sequence Validation

Once parsed, transaction streams must align with accounting periods and audit requirements before any fare rule touches them. Real-world validator deployments suffer from clock drift and offline batching: when devices reconnect they often upload out-of-order sequences. A reconciliation worker therefore has to detect duplicate taps, missing tap-outs, and sequence gaps deterministically, routing malformed frames to a dead-letter queue instead of crashing the batch.

Configure structured JSON logging that captures transaction_id, validator_mac, error_code, and raw_hex so revenue-assurance teams can reproduce any decision during firmware rollouts. The state machine below captures how each frame moves through the worker’s validation states, including the terminal sequence-gap halt:

Reconciliation worker state machine The worker starts in Parsing. A CRC or parse failure sends the frame to Quarantined and the dead-letter trail; a clean parse moves to Checking. From Checking, a duplicate transaction id is Skipped, an in-order unique frame is Accepted, and a sequence gap moves to Halted, which is terminal and stops the batch. Quarantined, Skipped, and Accepted all loop back to Parsing to process the next frame; Accepted also reaches batch completion. next frame parsed ok unique batch complete parse / CRC fail duplicate sequence gap stop Parsing unpack frame Checking dup + sequence Accepted in-order · unique Quarantined dead-letter Skipped re-uploaded Halted batch stops
def reconcile_stream(
    frames: Iterator[bytes],
    expected_sequence_start: int = 0,
) -> list[CalypsoTransaction]:
    """
    Ingestion worker with quarantine routing and strict sequence validation.
    Malformed frames are logged to a dead-letter audit trail and skipped;
    an unrecoverable gap raises SequenceGapError so the batch can be replayed.
    """
    valid_transactions: list[CalypsoTransaction] = []
    seen_tx_ids: set[int] = set()
    last_sequence = expected_sequence_start
    processed = 0

    for idx, frame in enumerate(frames):
        processed += 1
        try:
            tx = parse_calypso_frame(frame)
        except ChecksumValidationError as exc:
            logger.warning(json.dumps({
                "event": "CRC_FAILURE",
                "frame_index": idx,
                "raw_hex": frame.hex().upper(),
                "error": str(exc),
            }))
            continue
        except CalypsoParseError as exc:
            logger.error(json.dumps({
                "event": "PARSE_FAILURE",
                "frame_index": idx,
                "raw_hex": frame.hex().upper(),
                "error": str(exc),
            }))
            continue

        # Duplicate detection (re-uploaded offline batch)
        if tx.transaction_id in seen_tx_ids:
            logger.info(json.dumps({
                "event": "DUPLICATE_DETECTED",
                "transaction_id": tx.transaction_id,
                "validator_mac": tx.validator_mac,
            }))
            continue

        # Sequence gap detection (offline validator drift)
        if tx.transaction_id != last_sequence + 1:
            logger.warning(json.dumps({
                "event": "SEQUENCE_GAP",
                "expected": last_sequence + 1,
                "received": tx.transaction_id,
                "validator_mac": tx.validator_mac,
            }))
            raise SequenceGapError(
                f"Gap between {last_sequence} and {tx.transaction_id}"
            )

        last_sequence = tx.transaction_id
        seen_tx_ids.add(tx.transaction_id)
        valid_transactions.append(tx)

    logger.info(json.dumps({
        "event": "BATCH_COMPLETE",
        "total_processed": processed,
        "valid_count": len(valid_transactions),
        "audit_trail_id": "CALYPSO_RECON_"
        + datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ"),
    }))
    return valid_transactions

Transit-Specific Debugging Checklist

  1. Clock drift compensation. Offline validators often drift ±5 minutes. Normalize every timestamp to UTC the instant it is parsed, then cross-reference AFC controller heartbeat logs before any downstream code applies a fare window.
  2. Missing tap-out handling. Give unpaired tap-ins a grace period (typically 120–180 minutes). Flag them as potential incomplete journeys and route to a manual-review queue rather than auto-charging a maximum fare.
  3. Hex context preservation. Always log the raw hexadecimal payload alongside the parsed fields. Firmware updates frequently shift bit offsets; keeping the original hex enables forensic diffing against previous schema versions.
  4. Validator MAC routing. Group reconciliation by validator_mac to isolate hardware faults. A single validator emitting repeated CRC failures usually signals a degraded antenna or corrupted flash storage, not a bad card.

Validation & Test Cases

Because parse_calypso_frame is pure and deterministic, it is straightforward to test against hand-built frames. The helper below constructs a valid frame with a correct CRC so you can exercise both the happy path and the rejection paths. All monetary values stay out of this layer entirely, so the assertions concern identity, type, time, and zone only.

def build_frame(
    tx_id: int,
    uid: bytes,
    tx_type: int,
    mac: bytes,
    ts_epoch: int,
    zone_id: int,
) -> bytes:
    """Assemble a 27-byte Calypso frame with a valid CRC-16/CCITT trailer."""
    assert len(uid) == 8 and len(mac) == 6
    payload = (
        struct.pack(">I", tx_id)
        + uid
        + bytes([tx_type])
        + mac
        + struct.pack(">I", ts_epoch)
        + struct.pack(">H", zone_id)
    )
    return payload + struct.pack(">H", calculate_crc16_ccitt(payload))

Normal case — a valid tap-in decodes cleanly:

frame = build_frame(
    tx_id=1001,
    uid=bytes.fromhex("04A1B2C3D4E5F600"),
    tx_type=TransactionType.TAP_IN,
    mac=bytes.fromhex("00113355AACC"),
    ts_epoch=1_700_000_000,  # 2023-11-14T22:13:20Z
    zone_id=3,
)
tx = parse_calypso_frame(frame)
print(tx.transaction_id, tx.card_uid, tx.tx_type.name)
print(tx.validator_mac, tx.timestamp_utc.isoformat(), tx.fare_zone_id)

Expected output:

1001 04A1B2C3D4E5F600 TAP_IN
00:11:33:55:AA:CC 2023-11-14T22:13:20+00:00 3

Edge case — a single corrupted byte is caught by the checksum:

corrupt = bytearray(frame)
corrupt[5] ^= 0xFF  # flip a UID byte after the CRC was computed
try:
    parse_calypso_frame(bytes(corrupt))
except ChecksumValidationError as exc:
    print("rejected:", exc)

Expected output (the exact CRC values depend on the flipped byte):

rejected: CRC mismatch: expected 0x…, got 0x…

Error case — an unrecognized event type is refused, not coerced:

bad_type = build_frame(1002, b"\x00" * 8, 0x09, b"\x00" * 6, 1_700_000_000, 1)
try:
    parse_calypso_frame(bad_type)
except CalypsoParseError as exc:
    print("rejected:", exc)
# -> rejected: Unknown transaction type: 0x09

A short-read frame (fewer than 27 bytes, common when a validator’s flash is truncated mid-write) raises CalypsoParseError at the length gate before any unpacking runs, so a torn record can never masquerade as a valid transaction.

Integration Note

This decoder is one media adapter inside Smart Card Schema Mapping; its CalypsoTransaction is normalized into the same canonical tap record produced by the sibling MIFARE DESFire EV2 tap decoder, so two operators running different card media settle into one ledger schema. The fare_zone_id this parser emits is resolved against the fare zone taxonomy, and the decoded events flow downstream to the transfer window logic where transfers, capping, and pricing are finally applied — none of which happens here.

Frequently Asked Questions

Which CRC variant does the Calypso frame trailer use?

The compact validator log format used here carries a CRC-16/CCITT-FALSE trailer: polynomial 0x1021, initial value 0xFFFF, no reflection, and no final XOR. If your computed value is consistently wrong by a predictable transform, you are almost certainly using a reflected variant (CRC-16/CCITT reversed) or the 0x0000 init. Confirm against a known-good frame captured directly from the validator before trusting any batch.

Why reject a bad-checksum frame instead of trying to repair it?

A failed CRC means the bytes are untrustworthy, and the fields that price a journey — event type, zone, timestamp — sit inside that same corrupted payload. Heuristic repair guesses at money. The correct behavior is to quarantine the raw hex to the dead-letter audit trail, group failures by validator_mac to spot failing hardware, and reconcile the gap through the normal replay path rather than inventing a transaction.

The sequence check halts the whole batch on one gap — is that intended?

Yes, for a strictly ordered per-validator stream a real gap means frames were lost or reordered, and continuing would silently attribute later taps to the wrong sequence. Raising SequenceGapError forces an explicit replay or manual review. If your validators legitimately interleave streams, partition the frames by validator_mac first and run one reconcile_stream per device so each sequence is validated in isolation.

Should the parser ever compute a fare amount?

No. Decoding emits identity, event type, UTC time, and zone only; pricing stays downstream where it is done in Decimal. Folding fare math into the parser would both couple the media adapter to tariff logic and risk float drift on money. Keep this layer about structure and let the calculation engines own the arithmetic.

↑ Part of Smart Card Schema Mapping