Print a physical “credit voucher” every time a student redeems. The combination of math practice → real, tangible reward is genuinely motivating for kids — there’s nothing quite like a freshly-printed receipt with their name on it.

This recipe targets the Rongta 80mm POS printer (~$70 on Amazon — ESC/POS, USB + Serial + Ethernet, built-in auto-cutter), but the same code works with any other ESC/POS-compatible receipt printer.

What gets printed

========================================
              MATHNET
========================================

             [ * 1 * ]
             ONE CREDIT

    Pays bearer one credit on demand

    Earned on: Alex's iPad
    Date:      2026-05-13 12:30

========================================

Step 1: Set up the printer

The Rongta exposes three interfaces — pick one:

  • Ethernet (recommended): plug it into your router. Out of the box the printer comes up at the static IP 192.168.1.87 with DHCP disabled. Open http://192.168.1.87 in a browser (you may have to put a laptop on a temporary 192.168.1.x subnet to reach it), enable DHCP, then set a DHCP reservation on your router so its new IP stays put. The printer listens on TCP port 9100 (raw ESC/POS over network — the JetDirect convention).
  • USB: plug it directly into a Linux host (Raspberry Pi, NAS, etc.). Run lsusb to find the vendor/product IDs — Rongta units typically show up as 0fe6:811e (Sino-Wealth chipset) or similar.
  • Serial: only if you have a reason to use it.

Ethernet is the easiest because the relay can run on any machine on your LAN, including a Mac or a Docker host. USB ties the relay to the specific box the printer is plugged into and requires extra device-passthrough configuration for Docker.

Step 2: The relay script

Save this as relay.py:

from flask import Flask, request
from escpos.printer import Network, Usb
from datetime import datetime

app = Flask(__name__)

# --- Pick ONE of these ---
# Ethernet (recommended). 192.168.1.87 is the Rongta's factory default
# static IP; change it to your reserved DHCP address once you've set
# that up (see Step 1).
p = Network("192.168.1.87")

# USB alternative — find IDs with `lsusb`. Note: USB passthrough on
# Docker is fiddly, especially on Mac. Prefer Ethernet if you're
# running this in a container.
# p = Usb(0x0fe6, 0x811e)
# -------------------------


def _format_timestamp(raw):
    """MathNet sends ISO 8601 with a UTC offset, e.g.
    '2026-05-13T12:30:00-07:00'. Parse and render as a short local
    wall-clock time. Falls back to 'now' if anything is off."""
    if raw:
        try:
            return datetime.fromisoformat(raw).strftime("%Y-%m-%d %H:%M")
        except ValueError:
            pass
    return datetime.now().strftime("%Y-%m-%d %H:%M")


BORDER = "========================================\n"


def print_ticket(device_name, timestamp):
    """Print a banknote-style voucher on the Rongta 80mm."""
    # Top border + masthead
    p.set(align="center", bold=True, width=1, height=1)
    p.text(BORDER)
    p.set(align="center", bold=True, width=2, height=2)
    p.text("MATHNET\n")
    p.set(align="center", bold=True, width=1, height=1)
    p.text(BORDER)
    p.text("\n")

    # The "1" denomination
    p.set(align="center", bold=True, width=2, height=2)
    p.text("[ * 1 * ]\n")
    p.set(bold=True, width=1, height=1)
    p.text("ONE CREDIT\n\n")

    # Banknote tagline
    p.set(align="center", bold=False, width=1, height=1)
    p.text("Pays bearer one credit on demand\n\n")

    # Issuance details
    p.text(f"Earned on: {device_name}\n")
    p.text(f"Date:      {timestamp}\n\n")

    # Bottom border
    p.set(align="center", bold=True, width=1, height=1)
    p.text(BORDER)

    p.cut()


@app.route("/mathnet", methods=["POST"])
def webhook():
    data = request.json or {}
    event = data.get("event")

    # Only print on real redemptions. The Test webhook button in MathNet
    # Settings sends event='test' — return 200 so the in-app reachability
    # check passes, but don't waste paper on a test print.
    if event != "redeem":
        return "", 200

    print_ticket(
        device_name=data.get("device_name", "Unknown device"),
        timestamp=_format_timestamp(data.get("timestamp")),
    )
    return "", 200


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)

Step 3: Run it (pick one)

Option A: Docker Compose (easiest on Mac/Windows/any Docker host)

Python on macOS has gotten increasingly tricky — pip install requires either a virtual environment or --break-system-packages, and dependencies sometimes need compiled extensions. Docker sidesteps all of that. Best if your host doesn’t have the printer plugged in over USB (use Ethernet from Step 1).

In the same directory as relay.py, save:

Dockerfile

FROM python:3.12-slim
WORKDIR /app
RUN pip install --no-cache-dir python-escpos flask
COPY relay.py .
CMD ["python", "-u", "relay.py"]

docker-compose.yml

services:
  mathnet-printer:
    build: .
    container_name: mathnet-printer
    restart: unless-stopped
    ports:
      - "5000:5000"

Then:

docker compose up -d --build
docker logs -f mathnet-printer   # watch the output

The relay is now listening at http://<your-docker-host>:5000/mathnet.

Option B: Native Python (Linux / Raspberry Pi)

If you’re on a Pi with USB-connected printer, native Python is simpler:

pip install python-escpos flask
python relay.py

Use systemd (or pm2, supervisord, etc.) to keep it running on reboot.

Step 4: Wire it into MathNet

In MathNet → Settings:

  1. Toggle Enable reward system on
  2. Paste http://<your-relay-host>:5000/mathnet into Webhook URL (or put TLS in front of it via Caddy / Cloudflare Tunnel / Tailscale)
  3. Tap Save Settings
  4. Tap Test webhook — should return 200 without printing (the relay skips non-redeem events)
  5. Earn a credit and tap Redeem in the Bank — your voucher prints

Customizing the ticket

The print_ticket() function is intentionally short and editable. Swap in your own ASCII art, add a QR code (p.qr("https://mathnet.app/", size=6)), include the balance, change the masthead — whatever your kid will think is cool. The Rongta supports the full ESC/POS feature set: variable text sizes, bold, inverted, underline, barcodes, QR codes, raster images.

Security

Anything running on your home network with an open webhook port deserves a thought:

  • Don’t expose the relay directly to the public internet. Use Tailscale, WireGuard, a Cloudflare Tunnel, or just keep it LAN-only.
  • If you must expose it, set an Admin password in MathNet Settings — every redeem comes through with Authorization: Bearer <password> and your relay can verify it.
  • A long, random URL path (/mathnet-9f2a1b/) is a useful first line of defense.