Rust-powered thermal printer

Rust Thermal Printer

I thought it would be a fun side project to build out an always-online thermal printer so I could send messages from the office to my wife. With RTO in full swing, I’m back into the office four days a week and miss my little goblin running around the house (9 months old at the time of writing).

So, here is how I did it.

Getting Started

I used a raspberry pi to run the web server (headless pi). To get started, you’ll want to make sure you have it installed as well as having cloudflared installed. I recommend following the guide though, which can be found here for creating a remotely managed tunnel.

Remember how to get there, too - it’s under Zero Trust -> Networks -> Tunnels. I always forget and it’s like hunting to find it.

Alternatively, you can find the latest cloudflared binaries here, just ssh into your headless pi and get it installed as a service. Since it’s remotely managed, you can set it up in cloudflare later.

Installing Supervisor & Rust

Supervisor: sudo apt install supervisor -y && sudo service supervisor start

Rust: curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh

The Thermal Printer

I’m using a Rongta Thermal Receipt Printer - using the windows program from the official website to set a static ip on my network so I could connect to it from the pi.

The Rust

For the rust side, I leveraged an Actix web server to listen to incoming requests. There are a few scopes which help setup the functions to call based on the incoming request.

Packages

I didn’t write the thermal interface myself, but leveraged recibo to handle the printing.

[dependencies]
actix = "0.13.5"
actix-limitation = "0.5.1"
actix-web = "4.9.0"
actix-web-httpauth = "0.8.2"
chrono = "0.4.38"
dotenv = "0.15.0"
recibo = {version="0.3.0", features=["graphics", "serde"]}
serde = { version="1.0.209", features=["derive"] }
serde_json = "1.0.127"

Authentication

With all things online, you probably want to set up some sort of authentication when testing. Since this was just a silly project, I used this basic authentication middleware:

async fn validator(req: ServiceRequest, credentials: BasicAuth) -> Result<ServiceRequest, (Error, ServiceRequest)> {
    let username = credentials.user_id();
    let password = credentials.password().unwrap_or_default();

    if username == "FETCH_THIS_OR_JUST_PUT_IT_HERE" && password == "PASSWORD_OR_ANOTHER_THING_HERE" {
        Ok(req)
    } else {
        let response = HttpResponse::Unauthorized()
        .insert_header(("WWW-Authenticate", "Basic realm=\"Restricted Area\""))
        .finish();
        Err((error::ErrorUnauthorized("invalid"), req))
    }
}

Actix Main Function

The main function simply launches the actix web server, it’s pretty straight forward.

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    dotenv().ok();
    println!("Starting server...");
    HttpServer::new(|| {
        let auth = HttpAuthentication::basic(validator);
      
        let custom_requires_auth_scope = web::scope("/custom")
            .wrap(auth)  // Auth goes here!
            .service(custom_print_get)
            .service(custom_print_post);

        App::new()
            .wrap(NormalizePath::trim())
            .service(custom_requires_auth_scope)
            .service(status)
    })
    .bind(("127.0.0.1", 8080))?
    .run()
    .await
}

Helper Functions

There are two functions to print lines and also get the printer instance. I used dotenv, so you can set up the printer ip and port in your .env file.


fn get_printer() -> recibo::Result<Printer> {
    let printer_ip = env::var(PRINTER_IP).unwrap();
    let printer_port = env::var(PRINTER_PORT).unwrap();

    let printer_port: u16 = printer_port.parse().expect("Invalid printer port");
    
    let driver = NetworkDriver::open(printer_ip, printer_port)?;
    let printer = Printer::open(driver)?;

    Ok(printer)
}

fn print_custom_line(from: String, line: String) -> recibo::Result<()> {
    let mut printer = get_printer()?;

    printer
    .init()?
    .align(Alignment::Center)?
    .bold(true)?
    .text_size(1, 1)?
    .text("NEW MESSAGE")?
    .feed(1)?
    .reset_text_size()?
    .bold(false)?;

    printer.text(from)?;
    printer.feed(2)?;

    printer.align(Alignment::Left)?;
    printer.text(line)?;
    printer.feed(2)?;
    printer.align(Alignment::Center)?;
    printer.text(format!("Printed @ {}", chrono::Local::now().format("%Y-%m-%d %H:%M:%S")))?;
    printer.feed(6)?;
    printer.align(Alignment::Left)?;
    printer.cut()?.flush()?;

    Ok(())
}

The Printing Endpoints

custom_print_get() simply returns the html containing the form to submit a message (from, and message). custom_print_post() takes the form data and prints it out. status() is just a simple endpoint to check if the server is online.

#[get("")]
async fn custom_print_get() -> impl Responder {
    // return the contents of web/index.html
    HttpResponse::Ok().body(include_str!("../web/index.html"))
}

#[post("print")]
async fn custom_print_post(form: web::Form<FormData>) -> impl Responder {
    let max_length = 600;
    let truncated_message = if form.message.len() > max_length {
        form.message[..form.message.char_indices().nth(max_length).unwrap().0].to_string()
    } else {
        form.message.clone()
    };

    let truncated_from = if form.from.len() > max_length {
        form.from[..form.from.char_indices().nth(max_length).unwrap().0].to_string()
    } else {
        form.from.clone()
    };

    // replace any smart quotes with a single quote
    let truncated_message = truncated_message.replace("“", "\"").replace("”", "\"");
    let truncated_from = truncated_from.replace("“", "\"").replace("”", "\"");

    // do the same for ’
    let truncated_message = truncated_message.replace("’", "'");
    let truncated_from = truncated_from.replace("’", "'");

    match print_custom_line(truncated_from, truncated_message) {
        Ok(_) => println!("Custom Printed Message OK"),
        Err(e) => eprintln!("Error printing custom message: {:?}", e),
    }
    HttpResponse::Ok().body("OK!")
}

#[get("/status")]
async fn status() -> impl Responder {
    HttpResponse::Ok().body("Currently Online")
}

Keeping Things Online

Sometimes things panic! Here is the supervisord setup to keep things running:

[program:receipt]
command=/home/YOUR_USER_HERE/projects/receipt/target/release/YOUR_BINARY_NAME
directory=/home/YOUR_USER_HERE/projects/receipt/
autostart=true
autorestart=true
user=jl
stderr_logfile=/var/log/receipt.err.log
stdout_logfile=/var/log/receipt.out.log

And to install it, here is the script (make sure supervisor is installed and that you git pulled and installed rust on your pi)

#/bin/bash
cp ./receipt.conf /etc/supervisor/conf.d/receipt.conf

# let supervisor update the configs
sudo supervisorctl reread
sudo supervisorctl update

Building On Pi

Once you have things in order, git clone your repo and cargo build it to output the binary to target/release/[your_binary_name].

Cloudflare Configuration

Head into cloudflare and go to Zero Trust -> Networks -> Tunnels. You should see the tunnel you created earlier, and it should be healthy. Under Public Hostname, you can assign a subdomain to your tunnel and connect it to http://localhost:8080, which will forward it to Actix automatically.

That’s it! The endpoints will be up and running at /custom, with a post request to /custom being routed to the printer.