| Crates.io | trmnl |
| lib.rs | trmnl |
| version | 0.1.0 |
| created_at | 2025-12-14 19:08:42.067233+00 |
| updated_at | 2025-12-14 19:08:42.067233+00 |
| description | BYOS (Bring Your Own Server) framework for TRMNL e-ink displays |
| homepage | |
| repository | https://github.com/tsangha/trmnl-rs |
| max_upload_size | |
| id | 1984946 |
| size | 128,080 |
A Rust framework for building TRMNL BYOS (Bring Your Own Server) applications.
TRMNL is an e-ink display device with an ESP32-C3 microcontroller and 7.5" screen. It connects to WiFi and periodically polls a server for content to display.
Terminology:
Use this crate if you want to:
Don't use this crate if you:
If you have a TRMNL device:
If you're bringing your own device (BYOD):
Add to your Cargo.toml:
[dependencies]
trmnl = { version = "0.1", features = ["axum", "render"] }
axum = "0.8"
tokio = { version = "1", features = ["full"] }
Create a minimal server (see full examples below):
use axum::{routing::get, Json, Router};
use trmnl::{DeviceInfo, DisplayResponse};
async fn display(device: DeviceInfo) -> Json<DisplayResponse> {
// Your display logic here
Json(DisplayResponse::new("https://yourserver.com/image.png", "image.png"))
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/api/display", get(display));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Point your device to https://yourserver.com/api/display. The device will poll this endpoint and display whatever image URL you return.
┌─────────────┐ ┌─────────────────┐ ┌─────────────┐
│ TRMNL │ GET │ Your Server │ fetch │ Your Data │
│ Device │ ──────► │ (built with │ ◄─────► │ Sources │
│ │ ◄────── │ this crate) │ │ │
└─────────────┘ JSON └─────────────────┘ └─────────────┘
+ PNG
Your device polls your server every N seconds. Your server returns a JSON response pointing to a PNG image. The device downloads and displays it.
This crate helps you build a BYOS server - a Rust binary you can run anywhere:
| Deployment | Notes |
|---|---|
| Home server | Raspberry Pi, NAS, old laptop - works great |
| VPS | DigitalOcean, Linode, Hetzner, etc. |
| Cloud | AWS, GCP, Azure, Fly.io, Railway |
| Local machine | For development/testing |
Requirements:
render feature), Chrome/Chromium must be installedHome server tips:
http://192.168.1.100:3000 or http://myserver.local:3000)Best for: Dashboards, data displays, anything that changes frequently.
[dependencies]
trmnl = { version = "0.1", features = ["axum", "render"] }
axum = "0.8"
tokio = { version = "1", features = ["full"] }
use axum::{routing::get, Json, Router};
use trmnl::{DeviceInfo, DisplayResponse};
use trmnl::render::{render_html_to_png, RenderConfig};
async fn display(device: DeviceInfo) -> Json<DisplayResponse> {
// 1. Generate HTML (fetch your data, build your layout)
let html = format!(r#"
<html>
<body style="width:800px; height:480px; background:white; padding:20px;">
<h1>Hello from {}</h1>
<p>Battery: {}%</p>
</body>
</html>
"#, device.short_id(), device.battery_percentage().unwrap_or(0));
// 2. Render HTML to PNG
let png = render_html_to_png(&html, &RenderConfig::default()).await.unwrap();
// 3. Save to disk (your web server serves static files)
let filename = format!("{}.png", std::time::UNIX_EPOCH.elapsed().unwrap().as_secs());
std::fs::write(format!("/var/www/trmnl/{}", filename), &png).unwrap();
// 4. Return URL to the image
Json(DisplayResponse::new(
format!("https://myserver.com/trmnl/{}", filename),
filename,
))
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/api/display", get(display));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Requirements: Chrome or Chromium installed on your server.
Best for: Simple displays, images generated elsewhere, or when you can't install Chrome.
[dependencies]
trmnl = { version = "0.1", features = ["axum"] }
axum = "0.8"
tokio = { version = "1", features = ["full"] }
use axum::{routing::get, Json, Router};
use trmnl::{DeviceInfo, DisplayResponse};
async fn display(_device: DeviceInfo) -> Json<DisplayResponse> {
// Just point to an existing image
// (generated by a cron job, external service, etc.)
Json(DisplayResponse::new(
"https://myserver.com/current-display.png",
"current-display.png",
).with_refresh_rate(300)) // Check every 5 minutes
}
Automatically extracted from request headers when using axum:
async fn display(device: DeviceInfo) -> Json<DisplayResponse> {
device.mac_address // "AA:BB:CC:DD:EE:FF"
device.battery_voltage // Some(4.2)
device.battery_percentage() // Some(100)
device.firmware_version // Some("1.2.3")
device.rssi // Some(-45) (WiFi signal in dBm)
device.short_id() // "E:FF" (last 4 chars of MAC)
}
// Minimal
DisplayResponse::new("https://url/image.png", "image.png")
// With options
DisplayResponse::new("https://url/image.png", "image.png")
.with_refresh_rate(60) // Seconds until next poll (default: 60)
.with_firmware_update("https://url/firmware.bin") // Trigger OTA
.with_reset() // Reset device
Important: The filename must change when your image changes. The device compares filenames to detect updates. Use timestamps:
let filename = format!("{}.png", SystemTime::now()
.duration_since(UNIX_EPOCH).unwrap().as_secs());
use trmnl::render::RenderConfig;
let config = RenderConfig {
chrome_path: None, // Auto-detect, or Some("/path/to/chrome")
temp_dir: None, // System temp, or Some(PathBuf::from("/tmp"))
optimize: true, // Run through ImageMagick for smaller files
color_depth: 8, // Bits per channel
};
Your server implements:
| Endpoint | Method | Required | Purpose |
|---|---|---|---|
/api/display |
GET | Yes | Returns image URL |
/api/setup |
GET | No | Device registration |
/api/log |
POST | No | Receive device logs |
The device sends these headers:
ID: MAC addressBattery-Voltage: e.g., "4.2"FW-Version: Firmware versionRSSI: WiFi signal strengthRefresh-Rate: Current refresh rateBy default, BYOS endpoints are public—anyone who knows your URL can access them. The device's MAC address (in the ID header) identifies the device but doesn't authenticate it.
This crate provides optional token-based authentication via query parameters:
Configure your device URL with a token:
https://yourserver.com/api/display?token=your-secret-token
Set the token on your server (environment variable):
export TRMNL_TOKEN=your-secret-token
Validate it in your handler:
use axum::{Json, http::StatusCode};
use trmnl::{DeviceInfo, DisplayResponse, TokenAuth};
async fn display(
device: DeviceInfo,
auth: TokenAuth,
) -> Result<Json<DisplayResponse>, (StatusCode, &'static str)> {
// Validate against environment variable
// If TRMNL_TOKEN is not set, allows all requests (open access)
auth.validate_env("TRMNL_TOKEN")
.map_err(|e| (StatusCode::UNAUTHORIZED, e.message))?;
Ok(Json(DisplayResponse::new("https://...", "image.png")))
}
// Validate against a specific value
auth.validate("my-secret-token")?;
// Validate against environment variable (if not set, allows all requests)
auth.validate_env("TRMNL_TOKEN")?;
// Check if a token was provided (without validating)
if auth.has_token() { ... }
// Manual extraction (for non-axum use)
let auth = TokenAuth::from_query_string("token=secret&other=value");
The BYOS URL (including any ?token= parameter) is configured on the device during WiFi setup. To change it:
There's no way to change the BYOS URL without re-running WiFi setup—it's baked into the device's firmware configuration.
Important: If you add token authentication to an existing BYOS setup, your device will start getting 401 errors until you update the URL on the device.
When configuring your device, use this URL format:
https://yourserver.com?token=your-secret-token
The device will automatically append /api/display, /api/log, etc. to this base URL.
The TRMNL device uses a LiPo battery (3.0V-4.2V range). Battery drain depends primarily on refresh rate:
| Refresh Rate | Polls/Day | Expected Battery Life |
|---|---|---|
| 60s (1 min) | 1,440 | ~3-5 days |
| 300s (5 min) | 288 | ~2-3 weeks |
| 900s (15 min) | 96 | ~1-2 months |
| 1800s (30 min) | 48 | ~2-3 months |
| 3600s (1 hr) | 24 | ~3-4 months |
Tips for extending battery life:
Battery-Voltage headerdevice.battery_percentage() to display remaining chargeFor text-heavy dashboards (tasks, calendars, briefings), use HTML with Chrome headless rendering. The key is fixed pixel positioning—Chrome headless doesn't handle flexbox reliably.
width: 800px; height: 480px on bodyposition: absolute for major sections┌────────────────────────────────────────────────────────────┐
│ Header: Date/Time (left) Weather (right) │
├────────────────────────────┬───────────────────────────────┤
│ │ │
│ Left Column │ Right Column │
│ - Status/metrics │ - Briefing text │
│ - Task list │ - Quotes/highlights │
│ - Calendar/meetings │ - News/updates │
│ │ │
├────────────────────────────┴───────────────────────────────┤
│ Footer: Battery (left) Message (center) │
└────────────────────────────────────────────────────────────┘
See templates/dashboard.html for a complete example with:
| Element | Size | Use For |
|---|---|---|
| 20px | Headers | Date, main titles |
| 18px | Subheaders | Time, weather |
| 16px | Emphasis | Key values, footer message |
| 14-15px | Body | Section titles, quotes |
| 12-13px | Details | Task items, body text |
| 11px | Meta | Timestamps, sources |
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 800px;
height: 480px;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: white;
color: black;
}
.header {
position: absolute;
top: 10px;
left: 16px;
right: 16px;
height: 40px;
}
.columns {
position: absolute;
top: 55px;
left: 16px;
right: 16px;
bottom: 55px;
display: flex;
gap: 20px;
}
.column { flex: 1; overflow: hidden; }
.footer {
position: absolute;
bottom: 15px;
left: 16px;
right: 16px;
height: 35px;
border-top: 1px solid #ddd;
}
The schedule feature lets you configure different refresh rates based on time of day and day of week. This helps optimize battery life while keeping displays fresh when needed.
[dependencies]
trmnl = { version = "0.1", features = ["axum", "schedule"] }
Create a schedule config file:
# config/schedule.yaml
timezone: "America/New_York"
default_refresh_rate: 300 # 5 minutes (fallback if no rule matches)
schedule:
# Sleep hours - infrequent updates to save battery
- days: all
start: "23:00"
end: "06:00"
refresh_rate: 1800 # 30 minutes
# Morning routine - frequent updates
- days: weekdays
start: "06:00"
end: "09:00"
refresh_rate: 60 # 1 minute
# Work hours - moderate updates
- days: weekdays
start: "09:00"
end: "18:00"
refresh_rate: 120 # 2 minutes
# Weekend - relaxed
- days: weekends
start: "06:00"
end: "23:00"
refresh_rate: 600 # 10 minutes
all - Every dayweekdays - Monday through Fridayweekends - Saturday and Sunday["mon", "wed", "fri"] - Specific days (list format)monday / mon - Single dayOption 1: Global schedule (recommended for most apps)
use trmnl::{init_global_schedule, get_global_refresh_rate};
#[tokio::main]
async fn main() {
// Load once at startup
init_global_schedule("config/schedule.yaml");
// ... start your server
}
async fn display(device: DeviceInfo) -> Json<DisplayResponse> {
// Returns rate based on current time, or 60s if no schedule loaded
let refresh_rate = get_global_refresh_rate();
Json(DisplayResponse::new(url, filename)
.with_refresh_rate(refresh_rate))
}
Option 2: Manual schedule management
use trmnl::schedule::RefreshSchedule;
// Load schedule at startup
let schedule = RefreshSchedule::load("config/schedule.yaml")?;
// In your display handler
async fn display(device: DeviceInfo) -> Json<DisplayResponse> {
let refresh_rate = schedule.get_refresh_rate(); // Returns rate based on current time
Json(DisplayResponse::new(url, filename)
.with_refresh_rate(refresh_rate))
}
09:00 to 17:00 matches 9am-5pm23:00 to 06:00 matches 11pm-6am (spans midnight)09:00 to 17:00 does not include exactly 17:00| Feature | Dependencies Added | Use When |
|---|---|---|
axum |
axum, http | Building a web server (most users) |
render |
tokio | Generating images from HTML (requires Chrome) |
schedule |
chrono, chrono-tz, serde_yaml | Time-based refresh rate scheduling |
full |
All of the above | You want everything |
See the examples/ directory:
basic_byos.rs - Minimal BYOS serverwith_render.rs - HTML rendering exampleRun with:
cargo run --example basic_byos --features axum
cargo run --example with_render --features "axum render"
MIT