Skip to content

Quote Receipts

Teddy Warner's GitHub profile picture Teddy Warner| Fall, 2025 | 19–24 minutes

QUOTE RECEIPT
2025-10-04 21:48:01
"Did I really say that?" Why yes, you did.
-Teddy

I have some really wonderful friends. They, like I, say many silly things.

I wanted some means of capturing all of these quotes for later reference. Or to keep as nice souvenirs of thought.

My roommate and I have been going all in on the apartment projects (AIPhone, Cathode Ray Doorbell, StairGuitar™, etc. - I’ll do write-ups on some of these at some point in the future), and I figured to stay in the same vein that my weekend project should attempt to solve my quote attribution problem. I also happened to have an 80mm Thermal Receipt Printer lying around from another project I never got around to finishing.

And boom, I now knew how I would be spending my Saturday morning.

“That’s a Quote.”

The general premise of this build is pretty easy: people in my apartment say silly / interesting / funny things all the time. Upon hearing one of these silly / interesting / funny things, a user should be able to print said thing with little imposition. As such, we’ll need a locally hosted website, of course - A user will hear a quote, open said local website, and then a “quote receipt” will be generated and printed to memoralize that silly / interesting / funny moment for all of eternity.

This user flow demands a few things: first, a locally hosted webpage where a user can upload a quote. It’s probably important that this remains local to our apartment network, as I think it provides a nice scope for what quotes are fit to be printed (and so the gimmick doesn’t burn out too quickly). Also, this makes development easier. Second, a printer capable of quickly printing these “quote receipts”. I’ll be fitting this printer with a Raspberrry Pi 5 to handle the webserver hosting / printing.

Receipt [dot] Local

To start, lets take on this local webserver! For the sake of simplicity, I’ll be sticking to some skeuomorphism and designing a webform to look like a receipt. I started by flashing a fresh copy of Raspberry Pi OS Lite (64-bit) onto an SD card and updating the WiFi credentials.

Then I SSH’ed into the fresh RPI OS instance and got to work.

# SSH into your Pi
ssh pi@raspberrypi.local # Default password is usually 'raspberry'

# Update system
sudo apt update && sudo apt upgrade -y

A few config bits first:

# Set timezone
sudo raspi-config

# Navigate to: Localization Options > Timezone > Select yours

# Change default password (recommended)
passwd

Set Hostname to ‘receipt’ or whatever tickles your fancy, but this allows users to go to ‘receipt.local’ to upload their quote.

# Set hostname
sudo hostnamectl set-hostname receipt

# Update hosts file
sudo nano /etc/hosts

# Change the line: 127.0.1.1 raspberrypi
# To: 127.0.1.1 receipt
# Save: Ctrl+O, Enter, Ctrl+X

# Reboot to apply
sudo reboot

After reboot, reconnect with:

ssh pi@receipt.local

Then install Dependencies

# Install system packages
sudo apt install -y python3-pip python3-dev python3-pil libusb-1.0-0-dev avahi-daemon

# Install Python libraries (both user and system-wide for sudo)
pip3 install flask python-escpos pyusb pillow --break-system-packages
sudo pip3 install flask python-escpos pyusb pillow --break-system-packages

# Enable and start mDNS service
sudo systemctl enable avahi-daemon
sudo systemctl start avahi-daemon

# Reboot to ensure everything loads properly
sudo reboot

Plug in USB thermal printer, run lsusb to get vendor/product ID.

lsusb

Output example:

Bus 001 Device 005: ID 0483:5720 STMicroelectronics

Note: 0483:5720 → Vendor=0x0483, Product=0x5720

# Get USB endpoint addresses
lsusb -v -d 0483:5720 | grep -A 5 "bEndpointAddress"

Output example:

bEndpointAddress     0x03  EP 3 OUT
...
bEndpointAddress     0x81  EP 1 IN

Note your values: Vendor ID: 0x0483, Product ID: 0x5720, OUT Endpoint: 0x03, IN Endpoint: 0x81

Then we’ll create a very minimal project structure - I’m keeping this build to two files: a frontend HTML template and a simple Python backend.

# Create project directory
mkdir -p ~/receipt-printer/templates
cd ~/receipt-printer

# Create app.py
nano app.py

Paste this content into the new app.py file (update with YOUR vendor/product/endpoint IDs):

from flask import Flask, render_template, request, jsonify
from escpos.printer import Usb
from datetime import datetime
import textwrap

app = Flask(__name__)

# UPDATE THESE WITH YOUR PRINTER'S VALUES
VENDOR_ID = 0x0483      # Your vendor ID
PRODUCT_ID = 0x5720     # Your product ID
OUT_EP = 0x03           # Your OUT endpoint
IN_EP = 0x81            # Your IN endpoint

def print_quote(quote, author="Anonymous"):
    try:
        # Initialize printer with correct endpoints
        p = Usb(VENDOR_ID, PRODUCT_ID, out_ep=OUT_EP, in_ep=IN_EP)

        # Header
        p.set(align='center', bold=True, width=2, height=2)
        p.text("QUOTE RECEIPT\n")
        p.set(align='center', bold=False, width=1, height=1)
        p.text("=" * 32 + "\n")
        p.text(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
        p.text("=" * 32 + "\n\n")

        # Quote body
        p.set(align='left', bold=False)
        wrapped = textwrap.fill(f'"{quote}"', width=32)
        p.text(wrapped + "\n\n")

        # Attribution
        p.set(align='right', bold=False)
        p.text(f"-- {author}\n\n")

        # Footer
        p.set(align='center', underline=1)
        p.text("CERTIFIED STUPID\n")
        p.set(underline=0)
        p.text("No refunds. No context.\n")
        p.text("Memories printed. Dignity sold.\n\n")

        # QR code (optional)
        p.qr("https://receipt.local", size=6, center=True)
        p.text("\n")

        # Cut
        p.cut()
        p.close()

        return True

    except Exception as e:
        print(f"Print error: {e}")
        return False

@app.route('/')
def index():
    return render_template('index.html')

@app.route('/print', methods=['POST'])
def print_receipt():
    data = request.json
    quote = data.get('quote', '').strip()
    author = data.get('author', 'Anonymous').strip()

    if not quote:
        return jsonify({'success': False, 'error': 'Quote cannot be empty'}), 400

    success = print_quote(quote, author)

    if success:
        return jsonify({'success': True, 'message': 'Receipt printed!'})
    else:
        return jsonify({'success': False, 'error': 'Printer error'}), 500

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=80, debug=False)

Save: Ctrl+O, Enter, Ctrl+X

# Create frontend
nano templates/index.html

Paste this:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Quote Receipt Printer</title>
    <style>
        * { 
            margin: 0; 
            padding: 0; 
            box-sizing: border-box; 
        }

        body {
            font-family: 'Courier New', 'Courier', monospace;
            background: #ffffff;
            min-height: 100vh;
            display: flex;
            align-items: center;
            justify-content: center;
            padding: 20px;
        }

        .receipt {
            background: #fafafa;
            border: 1px dashed #999;
            padding: 30px 20px;
            max-width: 380px;
            width: 100%;
            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
        }

        .header {
            text-align: center;
            font-size: 1.8em;
            font-weight: bold;
            letter-spacing: 2px;
            margin-bottom: 15px;
        }

        .separator {
            text-align: center;
            margin: 10px 0;
            color: #666;
        }

        .timestamp {
            text-align: center;
            font-size: 0.9em;
            color: #666;
            margin: 10px 0;
        }

        .quote-section {
            margin: 20px 0;
        }


        textarea {
            width: 100%;
            padding: 10px;
            border: none;
            border-bottom: 1px solid #999;
            background: transparent;
            font-family: inherit;
            font-size: 1em;
            resize: none;
            min-height: 80px;
            outline: none;
        }

        textarea::placeholder {
            color: #aaa;
        }

        .author-section {
            text-align: right;
            margin: 15px 0;
        }

        input {
            width: 200px;
            padding: 8px;
            border: none;
            border-bottom: 1px solid #999;
            background: transparent;
            font-family: inherit;
            font-size: 0.95em;
            text-align: right;
            outline: none;
        }

        input::placeholder {
            color: #aaa;
        }

        .footer {
            text-align: center;
            margin-top: 20px;
            padding-top: 15px;
            border-top: 1px dashed #999;
        }

        .footer-title {
            text-decoration: underline;
            font-weight: bold;
            margin-bottom: 8px;
        }

        .footer-text {
            font-size: 0.85em;
            color: #666;
            line-height: 1.4;
        }

        button {
            width: 100%;
            padding: 15px;
            margin-top: 20px;
            background: #000;
            color: #fff;
            border: none;
            font-family: inherit;
            font-size: 1em;
            font-weight: bold;
            cursor: pointer;
            letter-spacing: 1px;
            transition: background 0.2s;
        }

        button:hover {
            background: #333;
        }

        button:active {
            background: #555;
        }

        button:disabled {
            background: #999;
            cursor: not-allowed;
        }

        .message {
            margin-top: 15px;
            padding: 12px;
            text-align: center;
            font-size: 0.9em;
            border-radius: 3px;
        }

        .success {
            background: #d4edda;
            color: #155724;
            border: 1px solid #c3e6cb;
        }

        .error {
            background: #f8d7da;
            color: #721c24;
            border: 1px solid #f5c6cb;
        }
    </style>
</head>
<body>
    <div class="receipt">
        <div class="header">QUOTE RECEIPT</div>
        <div class="separator">================================</div>
        <div class="timestamp" id="timestamp"></div>
        <div class="separator">================================</div>

        <form id="quoteForm">
            <div class="quote-section">
                <textarea 
                    id="quote" 
                    placeholder='"Enter quote here..."'
                    required
                ></textarea>
            </div>

            <div class="author-section">
                <input 
                    type="text" 
                    id="author" 
                    placeholder="-- Anonymous"
                >
            </div>

            <div class="footer">
                <div class="footer-title">CERTIFIED STUPID</div>
                <div class="footer-text">
                    No refunds. No context.<br>
                    Memories printed. Dignity sold.
                </div>
            </div>

            <button type="submit" id="submitBtn">
                PRINT RECEIPT
            </button>
        </form>

        <div id="message"></div>
    </div>

    <script>
        // Update timestamp every second
        function updateTimestamp() {
            const now = new Date();
            const formatted = now.getFullYear() + '-' + 
                String(now.getMonth() + 1).padStart(2, '0') + '-' + 
                String(now.getDate()).padStart(2, '0') + ' ' +
                String(now.getHours()).padStart(2, '0') + ':' + 
                String(now.getMinutes()).padStart(2, '0') + ':' + 
                String(now.getSeconds()).padStart(2, '0');
            document.getElementById('timestamp').textContent = formatted;
        }
        updateTimestamp();
        setInterval(updateTimestamp, 1000);

        const form = document.getElementById('quoteForm');
        const submitBtn = document.getElementById('submitBtn');
        const messageDiv = document.getElementById('message');

        form.addEventListener('submit', async (e) => {
            e.preventDefault();

            const quote = document.getElementById('quote').value.trim();
            const authorInput = document.getElementById('author').value.trim();
            const author = authorInput || 'Anonymous';

            if (!quote) {
                showMessage('Quote cannot be empty', 'error');
                return;
            }

            submitBtn.disabled = true;
            submitBtn.textContent = 'PRINTING...';

            try {
                const response = await fetch('/print', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({ quote, author })
                });

                const data = await response.json();

                if (data.success) {
                    showMessage('Receipt printed successfully', 'success');
                    form.reset();
                } else {
                    showMessage(data.error || 'Printing failed', 'error');
                }
            } catch (error) {
                showMessage('Network error', 'error');
            } finally {
                submitBtn.disabled = false;
                submitBtn.textContent = 'PRINT RECEIPT';
            }
        });

        function showMessage(text, type) {
            messageDiv.textContent = text;
            messageDiv.className = `message ${type}`;
            setTimeout(() => {
                messageDiv.textContent = '';
                messageDiv.className = '';
            }, 3000);
        }
    </script>
</body>
</html>

Save: Ctrl+O, Enter, Ctrl+X

I’m super happy with how the squedomorphic design came out here - Thermal Printers are somewhat limited in their output (due to binary color option) and as such I was pretty constrined when designing how I wanted the output reciept to look. Once i had a boilerplate from the backend, making this frontend match was easy.

To get our RPI app up and running with the printer, we need to set some permissions:

# Add user to printer groups (replace 'pi' with your username if different)
sudo usermod -a -G lp,dialout $USER

# Create udev rule (UPDATE with YOUR vendor/product IDs)
echo 'SUBSYSTEM=="usb", ATTR{idVendor}=="0483", ATTR{idProduct}=="5720", MODE="0666"' | \
sudo tee /etc/udev/rules.d/99-thermal-printer.rules

# Reload udev
sudo udevadm control --reload-rules
sudo udevadm trigger

# Reboot for permissions to take effect
sudo reboot

… and then we can test! Just be sure the printer is plugged into power, 80mm Thermal paper is loaded (I used MPRT 5 Rolls 3-1/8” x 230), and the printer is wired to the RPI via USB.

# Reconnect after reboot
ssh pi@receipt.local

# Navigate to project
cd ~/receipt-printer

# Test printer with YOUR endpoint values
python3 << 'EOF'
from escpos.printer import Usb
try:
    p = Usb(0x0483, 0x5720, out_ep=0x03, in_ep=0x81)
    p.text('Test Print\n')
    p.cut()
    print("✓ Printer working!")
except Exception as e:
    print(f"Error: {e}")
EOF

Then we can bring the whole thing together and test the web server.

sudo python3 app.py

You should see:

* Running on all addresses (0.0.0.0)
* Running on http://127.0.0.1:80

Test from browser: http://receipt.local

Press Ctrl+C to stop when done testing.

As a final step to prep for step 2: Printer Hacking, we’ll set up this app to auto run upon boot.

# Create systemd service (UPDATE 'User=' with your actual username)
sudo nano /etc/systemd/system/receipt-printer.service

Paste (update username in User= and WorkingDirectory= lines):

[Unit]
Description=Quote Receipt Printer
After=network-online.target avahi-daemon.service
Wants=network-online.target

[Service]
Type=simple
User=pi
WorkingDirectory=/home/pi/receipt-printer
ExecStart=/usr/bin/python3 /home/pi/receipt-printer/app.py
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

Save: Ctrl+O, Enter, Ctrl+X

# Enable and start service
sudo systemctl daemon-reload
sudo systemctl enable receipt-printer.service
sudo systemctl start receipt-printer.service

# Check status
sudo systemctl status receipt-printer.service

# Test reboot
sudo reboot

After reboot, http://receipt.local should be live automatically!

Here's the first print with the Quote Printer

Printer Hacking

What fun is a whimsical apartment project if it doesn’t look inconspicuous! To start, I removed the bottom of my miemieyo Thermal Receipt Printer to get a better sense of the space we have to work with by removing the two screws under the printer, as well as the two within the paper tray.

Unsurprisingly the internals of this receipt printer hardly fill the printer cavity, so retrofitting with our updated internals should be no problem at all. While the base piece that came on the machine has a suprisingly perfect cut out to fit a Raspberry Pi (almost like they were asking for this quote printer to be built), I opted to redesign the base of the printer entirly to allow for proper mounting of the stock printer mainboard, as well as the Raspberry Pi 5 and the LM2596 Buck Converter I’m using to power it.

I printed this new base on my Prusa I3 MK3S+, cleaned it up, and then prepared the thermal printer for installation. This primarly involved unmounting and detaching all plugs from the mainboard to prepare to mount it to the new base. Additionally, I used some wire snippers to remove the old mainboard mounts (as pictured below)

Then I started mounting components to the 3D printed baseplate, first the raspberry pi, then the buck converter.

Before continuing with the mainboard mounting, I wired the - IN terminal of the buck converter to the 24V in connector on the printer mainboard and the + IN to the printer power switch(note polarity below), and then used the potentiometer on the buck converter to set the output voltage to 5V.

I wired the +/- 5V output lines from the buck converter to the Raspberry Pi’s GPIO, connected the printer mainboard to the Raspberry Pi via USB, and plugged in the printer mainboard’s power (don’t actually attach this to the wall yet, get everything mounted and closed up first) and then mounted the mainboard to the 3D printed baseplate (you’ll need to plug in all the wires before mounting, its a tight fit).

I then reattached the printer mainboard wires,

… and attached the new base plate to the printer (wiggling all the wires into place to ensure nothing gets caught/clamped), reattached the screws in the paper tray, and finally, the screws under the printer. Amazing - the quote receipt printer is good to go!

BOM

Building a quote receipt printer of your own is easy enough. Here’s all you need:

Qty Description Price Link Notes
1 miemieyo Thermal Receipt Printer 80mm $65.99 Amazon
1 MPRT 5 Rolls Thermal Paper 3-1/8” x 230’ $15.99 Amazon
1 LM2596 Buck Converter $7.99 Amazon
1 Raspberry Pi 5 $79.95 Raspberry Pi Any Pi model should work - I happened to have a RPI5 8GB lying around
Some spare wire
Friends that say silly things Free Find My Friends

Quotebook

This thing is so awesome. I’ve had some great fun printing out quotes this weekend and attached a few favorites below. I’ll update this every once in a while as I log more silly things.

Also, thermal printers are really wonderful pieces of technology. I was astonished by how quickly these things print, very low latency from silly quote being said to quote receipt in hand. To stress test, I decided to print the entire Bee Movie script.

I hung the results like tinsel in my living room.

p.s. This “i before e except after c” shenanigans really throws me off. Apologies in advance for any “reciepts” left in this piece lol.

CERTIFIED STUPID
No refunds. No context.
Memories printed. Dignity sold.

Comments