Skip to content

eljojo/estrella

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

129 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

⭐️ estrella

A Rust library for Star Micronics TSP650II thermal receipt printers over Bluetooth. Implements the StarPRNT protocol with a React-inspired component system, an optimizing compiler, and a web UI for photo printing.

My Desk

Photo Printing

The web UI supports printing photos with real-time dithered preview:

  • Formats: JPEG, PNG, GIF, WEBP, HEIC (iPhone photos)
  • Adjustments: Rotation, brightness, contrast
  • Dithering: Choose algorithm for best results
  • Auto-resize to 576px printer width
Screenshot 2026-01-23 at 18 19 25

Image Downloads

Documents can include images from URLs. When a document is submitted via the JSON API, images are automatically downloaded, cached, resized, and dithered for printing.

{"type": "image", "url": "https://example.com/photo.jpg"}
  • Auto-resize: Images are scaled to the printer's full width (576 dots) preserving aspect ratio
  • Max height: Optional height field acts as a cap — if the resized image is taller, it shrinks to fit
  • Alignment: Images narrower than paper width are centered by default ("align": "center"). Also accepts "left" or "right"
  • Dithering: Defaults to Floyd-Steinberg. Set "dither" to "bayer", "atkinson", "jarvis", or "none"
  • Caching: Downloaded images are cached in memory and shared with photo sessions (30-min TTL), so previewing a document multiple times won't re-download
{
  "type": "image",
  "url": "https://example.com/photo.jpg",
  "width": 400,
  "height": 300,
  "align": "center",
  "dither": "atkinson"
}

The Document System

Instead of manually constructing printer escape sequences, Estrella provides a declarative Document model. The same types work for both Rust construction and JSON deserialization — one set of types, zero conversion layer.

use estrella::document::*;

let doc = Document {
    document: vec![
        Component::Banner(Banner::new("CHURRA MART")),
        Component::Text(Text { content: "2026-01-20 12:00:00".into(), center: true, ..Default::default() }),
        Component::Spacer(Spacer::mm(3.0)),
        Component::Banner(Banner { content: "TODAY ONLY: 50% OFF".into(), border: BorderStyle::Double, size: 2, ..Default::default() }),
        Component::Divider(Divider::default()),
        Component::LineItem(LineItem::new("Espresso", 4.50)),
        Component::LineItem(LineItem::new("Croissant", 3.25)),
        Component::Divider(Divider::default()),
        Component::Total(Total { amount: 7.75, bold: Some(true), double_width: true, ..Default::default() }),
        Component::Spacer(Spacer::mm(3.0)),
        Component::QrCode(QrCode { data: "https://example.com/rewards".into(), cell_size: Some(6), ..Default::default() }),
        Component::Text(Text { content: "Thank you!".into(), center: true, bold: true, ..Default::default() }),
    ],
    cut: true,
    ..Default::default()
};

let bytes = doc.build();                     // StarPRNT bytes, ready to send
let json = serde_json::to_string(&doc)?;     // Same type serializes to JSON

Demo Receipt

Components

Component Description
Text Styled text (bold, center, invert, size 0–3, optional font: "ibm")
Header Pre-styled centered bold header
Banner Framed text with box-drawing borders, auto-sizing (optional font: "ibm")
LineItem Left name + right price (e.g., "Coffee" ... "$4.50")
Total Right-aligned total line
Divider Horizontal line (dashed, solid, double, equals)
Spacer Vertical space in mm, lines, or raw units
Columns Two-column layout (left + right)
Table Table with box-drawing borders, headers, per-column alignment
Markdown Rich text from Markdown (headings, bold, lists)
Image Image from URL (downloaded, cached, dithered, auto-centered)
Pattern Generative art pattern with params
Canvas Absolute-positioned raster compositing with blend modes
QrCode, Pdf417, Barcode 1D and 2D barcodes
NvLogo Logo from printer's flash memory

Dithering Algorithms

Thermal printers are binary (black or white), so grayscale images need dithering. Estrella implements four algorithms:

Algorithm Characteristics
Floyd-Steinberg Classic error diffusion. Smooth gradients, organic look. Default for photos.
Atkinson Bill Atkinson's Mac algorithm. Higher contrast, loses 25% of error intentionally.
Jarvis Spreads error over 12 neighbors. Smoothest gradients, slightly slower.
Bayer Ordered 8x8 matrix. Fast, deterministic, halftone pattern. Best for patterns.
Floyd-Steinberg Atkinson Jarvis Bayer
Floyd-Steinberg Atkinson Jarvis Bayer

Pattern Generation

The web ui allows to preview patterns

Procedural patterns for artistic prints and printer calibration. Each pattern has randomizable parameters.

Ripple Waves Plasma
Ripple Waves Plasma
More patterns

Op Art

Riley Vasarely Scintillate Moire
Riley Vasarely Scintillate Moire

Organic

Topography Rings Flowfield Mycelium
Topography Rings Flowfield Mycelium
Erosion Crystal Reaction Diffusion Voronoi
Erosion Crystal Reaction Diffusion Voronoi

Glitch

Glitch Corrupt Barcode Databend Scanline Tear
Glitch Corrupt Barcode Databend Scanline Tear

Generative

Attractor Automata Estrella
Attractor Automata Estrella

Textures

Crosshatch Stipple Woodgrain Weave
Crosshatch Stipple Woodgrain Weave

Calibration

Calibration Microfeed Density Jitter
Calibration Microfeed Density Jitter
Overburn
Overburn

Pattern Weaving

Blend multiple patterns with DJ-style crossfade transitions:

estrella weave ripple plasma waves --length 200mm --crossfade 30mm

Weave Crossfade

JSON API

The JSON API uses the same Document type as the Rust API — the component structs are all Serialize + Deserialize, so JSON documents map directly to Rust types with zero conversion. Useful for automations (e.g. Home Assistant daily briefings).

curl -X POST http://localhost:8080/api/json/print \
  -H 'Content-Type: application/json' \
  -d '{
    "document": [
      {"type": "banner", "content": "GOOD MORNING"},
      {"type": "text", "content": "Monday, January 27", "center": true, "size": 0},
      {"type": "divider", "style": "double"},
      {"type": "text", "content": " WEATHER ", "bold": true, "invert": true},
      {"type": "columns", "left": "Now", "right": "6°C Cloudy"},
      {"type": "columns", "left": "High / Low", "right": "11°C / 3°C"},
      {"type": "divider"},
      {"type": "text", "content": " CALENDAR ", "bold": true, "invert": true},
      {"type": "columns", "left": "9:00", "right": "Standup"},
      {"type": "columns", "left": "11:30", "right": "Dentist"},
      {"type": "divider"},
      {"type": "qr_code", "data": "https://calendar.google.com"}
    ],
    "cut": true
  }'

The web UI includes a JSON API tab with a live preview editor and a sample daily briefing template.

Canvas components support absolute-positioned compositing with blend modes:

{
  "type": "canvas",
  "height": 100,
  "elements": [
    {"type": "pattern", "name": "estrella", "height": 80, "position": {"x": -43, "y": 0}, "blend_mode": "add"},
    {"type": "text", "content": "Hello World", "center": true, "position": {"x": 7, "y": 16}, "blend_mode": "add"},
    {"type": "total", "amount": 0, "position": {"x": 0, "y": 34}, "blend_mode": "add"}
  ]
}

Elements without position stack top-to-bottom (flow mode). Dithering defaults to "auto" — Atkinson when continuous-tone content is detected, none otherwise.

Endpoints:

  • POST /api/json/preview — returns a PNG preview
  • POST /api/json/print — sends to printer
Full component reference

Each component in the "document" array has a "type" field and type-specific properties:

Type Required Optional (defaults)
text content bold, underline, upperline, invert, upside_down, reduced (false); smoothing (null/auto); align ("left"), center, right (false); size (1, default Font A — 0=Font B, 2=double, 3=triple, or [h,w]); scale (null); double_width, double_height (false); inline (false); font (null — set "ibm" for IBM Plex Sans)
header content variant: "normal" (2x2 centered bold) or "small" (1x1)
banner content size (3, max expansion 0–3, auto-cascades width); border: "single"/"double"/"heavy"/"shade"/"shadow"; bold (true); padding (1); font (null — set "ibm" for IBM Plex Sans)
line_item name, price width (48)
total amount label ("TOTAL:"), bold (true), double_width (false), align ("right")
divider style: "dashed" / "solid" / "double" / "equals"; width (48)
spacer one of: mm, lines, units
blank_line
columns left, right width (48), bold, underline, invert (false)
table rows headers (null), border: "single"/"double"/"mixed"/"heavy"/"shade" (default: "single"); align ([] — per-column: "left"/"center"/"right"); row_separator (false); width (48)
markdown content show_urls (false)
qr_code data cell_size (4), error_level ("M"), align ("center")
pdf417 data module_width (3), ecc_level (2), align ("center")
barcode format, data height (80); format: "code128" / "code39" / "ean13" / "upca" / "itf"
image url dither ("floyd-steinberg"), width (576), height (null), align ("center" — also "left", "right"; only affects images narrower than paper)
pattern name height (500), params ({}), dither ("bayer")
canvas elements height (auto), width (576), dither ("auto" — detects continuous-tone content); each element: position ({x, y}), blend_mode ("normal"), opacity (1.0) + any component fields
nv_logo key center (false), scale (1), scale_x (1), scale_y (1)

Text size controls both font selection and character expansion using a 1-indexed model:

Size Font Expansion Chars/line Description
0 B (9×24) none 64 Small text
1 A (12×24) none 48 Normal (default)
2 A 24 Double
3 A 16 Triple

Pass a single number for uniform scaling ("size": 2 = double height and width), or an [h, w] array for independent control ("size": [3, 1] = triple height, normal width, 48 chars/line).

The banner component uses the same sizing model but auto-selects the largest width that fits. Given "size": 3, it tries widths 3→2→1→Font B until the content fits inside the box-drawing frame.

cut at the top level defaults to true. Set to false to suppress the paper cut.

How It Works: The Compilation Pipeline

Document components emit an intermediate representation (IR), which gets optimized before generating StarPRNT bytes:

┌─────────────────────────────────────────────────────────────────────────────┐
│                              COMPILATION PIPELINE                           │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│   ┌─────────────┐      ┌─────────────┐      ┌─────────────┐      ┌───────┐  │
│   │  Document   │      │     IR      │      │  Optimizer  │      │ Bytes │  │
│   │             │      │             │      │             │      │       │  │
│   │  Text       │      │  Op::Init   │      │  4 passes   │      │ ESC @ │  │
│   │  LineItem   │ ───► │  Op::Text   │ ───► │  that remove│ ───► │ ...   │  │
│   │  QrCode     │ emit │  Op::Bold   │      │  redundant  │ gen  │ 1D 69 │  │
│   │  ...        │      │  Op::Cut    │      │  operations │      │ ...   │  │
│   │             │      │  ...        │      │             │      │       │  │
│   └─────────────┘      └─────────────┘      └─────────────┘      └───────┘  │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

Optimizer Passes

The optimizer runs four passes to eliminate redundant operations:

Pass 1: Remove Redundant Init     Pass 2: Collapse Style Toggles
┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄     ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄
Init  ←── keep                    SetBold(true)
...                               Text("Hello")
Init  ←── remove                  SetBold(false) ┐
...                               SetBold(true)  ┘── remove pair
Init  ←── remove                  Text("World")


Pass 3: Remove Redundant Styles   Pass 4: Merge Adjacent Text
┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄    ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄
SetBold(true)                     Text("Hello")  ┐
Text("A")                         Newline        │── merge into
SetBold(true) ←── remove          Text("World")  ┘   "Hello\nWorld"
Text("B")

Result: 11 ops → 6 ops, ~5-8% smaller output with identical visual results.

Running at Home

Prerequisites

  • Printer: Star Micronics TSP650II (or compatible StarPRNT printer)
  • Connection: Bluetooth, paired to create /dev/rfcomm0
  • Build: Nix (recommended) or Rust 1.75+

Quick Start

# With Nix
nix develop
cargo run -- serve
# Open http://localhost:8080

# Without Nix
cargo build --release
./target/release/estrella serve --device /dev/rfcomm0

NixOS Module

For a proper deployment on NixOS:

{
  inputs.estrella.url = "github:eljojo/estrella";

  # Add to your flake outputs:
  nixpkgs.overlays = [ inputs.estrella.overlays.default ];

  # Enable the service:
  services.estrella = {
    enable = true;
    port = 8080;
    deviceMac = "00:11:22:33:44:55";  # Your printer's Bluetooth MAC
    # rfcommChannel = 0;  # Optional, defaults to 0
  };
}

The module creates two systemd services:

  • estrella-rfcomm.service - Oneshot that sets up the Bluetooth RFCOMM device (runs as root)
  • estrella.service - The HTTP daemon (runs unprivileged with DynamicUser)

Bluetooth Setup

Important: The TSP650II ships in SSP (Secure Simple Pairing) mode which doesn't work with Linux. You need the Star Settings iOS/Android app to:

  1. Disable "Auto Connect" (SSP mode)
  2. Set a PIN code

Without this step, pairing will silently fail. This was the hardest part to debug.

Pairing:

bluetoothctl
> power on
> agent on
> default-agent
> scan on
# Find your printer's MAC address (starts with 00:12:F3 for Star)
> pair XX:XX:XX:XX:XX:XX
> trust XX:XX:XX:XX:XX:XX
> connect XX:XX:XX:XX:XX:XX

Bind rfcomm device (automatic):

sudo estrella setup-rfcomm XX:XX:XX:XX:XX:XX
# Connects, verifies with l2ping, and creates /dev/rfcomm0

Bind rfcomm device (manual):

sudo rfcomm bind 0 XX:XX:XX:XX:XX:XX 1
# Creates /dev/rfcomm0

Permissions: Add your user to the dialout group for /dev/rfcomm0 access:

sudo usermod -aG dialout $USER
# Log out and back in

Debug commands:

bluetoothctl info XX:XX:XX:XX:XX:XX
l2ping -c 1 XX:XX:XX:XX:XX:XX
sdptool browse XX:XX:XX:XX:XX:XX

CLI Reference

estrella print ripple              # Print a pattern
estrella print ripple --png out.png  # Preview to PNG
estrella print --list              # List patterns
estrella serve                     # Start web server
estrella weave ripple plasma --length 200mm  # Blend patterns
estrella logo store logo.png       # Store logo in NV memory
estrella setup-rfcomm XX:XX:XX:XX:XX:XX  # Set up Bluetooth RFCOMM (requires root)
Long Print Mode (Buffer Overflow Prevention)

The Problem

Thermal printers have limited internal buffers (~100-200KB). Large images can overflow the buffer causing print failures.

The Solution

Estrella automatically splits large images into multiple independent print jobs (~1000 rows each). Each job completes fully before the next begins, preventing buffer overflow.

How It Works

Large Image (e.g., 2000 rows)
    ↓
split_for_long_print()
    ↓
Job 1: Init + Raster(rows 0-999)
    ↓ [1 second pause]
Job 2: Init + Raster(rows 1000-1999) + Feed + Cut
    ↓
Printer outputs seamless image
  • Each job is a complete, independent StarPRNT program
  • NO feed between jobs (image appears continuous)
  • Feed/Cut only on the final job
  • 1 second pause between jobs lets printer catch up

Usage

Long print mode is automatic when using the web UI or CLI.

For programmatic use:

let programs = program.split_for_long_print();
transport.send_programs(&programs)?;

Technical Details

Parameter Value
Chunk size 1000 rows (~125mm, ~72KB)
Pause between jobs 1 second
Band mode alignment 24-row boundaries

Fonts

The preview renderer embeds bitmap fonts for receipt rendering:

  • Spleen 12×24 — Font A (48 chars/line). Copyright (c) 2018-2024 Frederic Cambus. BSD 2-Clause license.
  • UW ttyp0 9×18 — Font B/C (64 chars/line, scaled vertically to 9×24 / 9×17). Copyright (c) 2012-2015 Uwe Waldmann. ttyp0 license (MIT-like).
  • IBM Plex Sans — Optional TTF font for Text and Banner components ("font": "ibm"). Anti-aliased rendering via ab_glyph, dithered to 1-bit. Copyright (c) IBM Corp. Apache 2.0 license.

Emojis

Emojis obtained from Emojipedia.

About

A print server for the StarPRNT protocol

Topics

Resources

Stars

Watchers

Forks