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.
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
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
heightfield 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"
}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| 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 |
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 |
|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
Procedural patterns for artistic prints and printer calibration. Each pattern has randomizable parameters.
![]() |
![]() |
![]() |
|---|---|---|
| Ripple | Waves | Plasma |
More patterns
Op Art
![]() |
![]() |
![]() |
![]() |
|---|---|---|---|
| Riley | Vasarely | Scintillate | Moire |
Organic
![]() |
![]() |
![]() |
![]() |
|---|---|---|---|
| Topography | Rings | Flowfield | Mycelium |
![]() |
![]() |
![]() |
![]() |
|---|---|---|---|
| Erosion | Crystal | Reaction Diffusion | Voronoi |
Glitch
![]() |
![]() |
![]() |
![]() |
|---|---|---|---|
| Glitch | Corrupt Barcode | Databend | Scanline Tear |
Generative
![]() |
![]() |
![]() |
|---|---|---|
| Attractor | Automata | Estrella |
Textures
![]() |
![]() |
![]() |
![]() |
|---|---|---|---|
| Crosshatch | Stipple | Woodgrain | Weave |
Calibration
![]() |
![]() |
![]() |
![]() |
|---|---|---|---|
| Calibration | Microfeed | Density | Jitter |
![]() |
|---|
| Overburn |
Blend multiple patterns with DJ-style crossfade transitions:
estrella weave ripple plasma waves --length 200mm --crossfade 30mmThe 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 previewPOST /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 | 2× | 24 | Double |
3 |
A | 3× | 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.
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 │ │ ... │ │
│ │ │ │ ... │ │ │ │ │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ └───────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
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.
- Printer: Star Micronics TSP650II (or compatible StarPRNT printer)
- Connection: Bluetooth, paired to create
/dev/rfcomm0 - Build: Nix (recommended) or Rust 1.75+
# With Nix
nix develop
cargo run -- serve
# Open http://localhost:8080
# Without Nix
cargo build --release
./target/release/estrella serve --device /dev/rfcomm0For 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)
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:
- Disable "Auto Connect" (SSP mode)
- 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:XXBind rfcomm device (automatic):
sudo estrella setup-rfcomm XX:XX:XX:XX:XX:XX
# Connects, verifies with l2ping, and creates /dev/rfcomm0Bind rfcomm device (manual):
sudo rfcomm bind 0 XX:XX:XX:XX:XX:XX 1
# Creates /dev/rfcomm0Permissions: Add your user to the dialout group for /dev/rfcomm0 access:
sudo usermod -aG dialout $USER
# Log out and back inDebug 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:XXestrella 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)
Thermal printers have limited internal buffers (~100-200KB). Large images can overflow the buffer causing print failures.
Estrella automatically splits large images into multiple independent print jobs (~1000 rows each). Each job completes fully before the next begins, preventing buffer overflow.
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
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)?;| Parameter | Value |
|---|---|
| Chunk size | 1000 rows (~125mm, ~72KB) |
| Pause between jobs | 1 second |
| Band mode alignment | 24-row boundaries |
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 viaab_glyph, dithered to 1-bit. Copyright (c) IBM Corp. Apache 2.0 license.
Emojis obtained from Emojipedia.






































