Skip to content

usetrmnl/byos_hanami

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Terminus

CircleCI Docker Code Coverage Style

Terminus is a Ruby/Hanami web server that allows you to manage TRMNL devices running on your own local network or hosted cloud. This is also the flagship BYOS implementation officially supported by TRMNL. For a quick introduction on TRMNL devices, check out the following 9to5Mac overview:

YouTube Video

Features

The following is a high level overview you can use to compare/contrast when deciding between using this Build Your Own Server (BYOS) implementation or our hosted solution.

Legend

  • ⚪️ Planned.

  • 🟢 Supported.

  • 🟡 Partially supported.

  • đź”´ Not supported, not implemented, or isn’t applicable.

Matrix

Terminus Hosted

Dashboard

🟢

🟢

Auto-Provisioning

🟢

🟢

Devices

🟢

🟢

JSON Data API

🟢

🟢

Image Previews

🟢

🟢

Playlists

🟢

🟢

Plugins*

🟢

🟢

Recipes*

🟢

🟢

Account Management

⚪️

🟢

Docker

🟢

đź”´

ℹ️ Plugins and Recipes are supported by pulling screen data from our Core server. This means Terminus accesses data outside your private network to acquire this data. This is done by proxying, per device, to our Core server (disabled by default), downloading screens from your playlist, and then rendering on your device. For more information, see Background Pollers.

The goal isn’t for BYOS to match parity with our hosted solution but to provide enough of a pleasant solution for your own customized experience. There are trade offs either way but we’ve got you covered for whatever path you wish to travel. 🎉

Requirements

  1. Ruby.

  2. PostgreSQL.

  3. Hanami.

  4. Docker (optional).

  5. A TRMNL device.

Setup

To set up this project, run:

git clone https://github.com/usetrmnl/byos_hanami terminus
cd terminus
bin/setup

đź’ˇ The setup script is idempotent so you can run it multiple times without harm. To rebuild a file managed by the setup script, delete the desired file and rerun setup to recreate.

Usage

To launch the server, run:

# Development
overmind start --port-step 10 --procfile Procfile.dev --can-die assets,migrate

# Production
overmind start --port-step 10 --can-die assets,migrate

To view the app, use either of the following:

Configuration

There are a few environment variables you can use to customize behavior:

  • API_URI: Used for connecting your device to this server or via Docker. Defaults to the wired IP address and port of the server you are running Terminus on. This also assumes you are connecting your device directly to the same server Terminus is running on. If this is not the case and you are using a reverse proxy, DNS, or any service/layer between your device and Terminus then you need to update this value to be your host. For example, if your host is http://demo.io then this value must be http://demo.io. This includes updating your device, via the TRMNL captive Wifi portal, to be using http://demo.io as your custom host too. How you configure http://demo.io to resolve to the server you are running Terminus on is up to you. All your device (and this value) cares about is what the external host (or IP and port) is for the device to make API requests too (they must be identical).

  • DATABASE_URL: Necessary to connect to your PostgreSQL database. Can be customized by changing the value in the .env.development or .env.test file created when you ran bin/setup.

  • FIRMWARE_POLLER: Enables/disables firmware polling. See Background Pollers for details. Defaults to enabled.

  • HANAMI_PORT: The default port when running the app locally or via Docker. When using Docker, this is used for the internal and external port mapping.

  • MODEL_POLLER: Enables/disables model polling. See Background Pollers for details. Defaults to enabled.

  • RACK_ATTACK_ALLOWED_SUBNETS: Defines the Rack Attack subnets that are allowed to connect to this server which helps when adding DNS, a reverse proxy, or a VPN, etc. between your device and this application so you can use this environment variable to add more subnets as desired. This takes a single subnet/IP or an array — with no spaces — of subnets/IPs as values. Example: "111.111.111.111,150.120.0.0/16". Alternatively, you can disable Rack Attack altogether by removing the config.middleware.use Rack::Attack line from config/app.rb or customize Rack Attack via the config/initializers/rack_attack.rb file. Any of these approaches will allow you to get your service layer properly configured so your device can talk to this server. By default, the following subnets are allowed: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.1, and ::1.

  • PG_DATABASE: Defines your database name. Used by Docker only. Default: terminus.

  • PG_PASSWORD: Defines your database password. Used by Docker only. Default: (auto-generated for you during setup).

  • PG_PORT: Defines your database port. Used by Docker only. Default: 5432.

  • PG_USER: Defines your database user. Used by Docker only. Default: terminus.

  • SCREEN_POLLER: Enables/disables model polling. See Background Pollers for details. Defaults to enabled.

Device Provisioning

There are a couple of ways you can provision a device with this server.

The first is automatic which happens immediately after you have successfully used the WiFi captive portal on your mobile phone to connect your TRMNL device to your local network where this server is running. You can also delete your device, via the UI, and it’ll be reconfigured for you automatically when the device next makes a Display API request.

The second way is to manually add your device via the UI. At a minimum, you only need to know your device’s MAC Address when entering your device information within the UI.

Background Pollers

There are a few background pollers that cache data from the remote Core server for improved performance:

  • Firmware (bin/pollers/firmware): Downloads the latest firmware for updating your local devices. By default, this checks for updates every six hours.

  • Model (bin/pollers/model): Synchronizes model information from the Core API. New models, as they become available, are added to the database while existing models, if updated, will be updated. By default, this checks for updates once a day.

  • Screen (bin/pollers/screen): Downloads device screens for any device you have set up to proxy to the Core server. You only need to toggle proxy support for any/all devices you want to pull from Core. This allows you to leverage any/all recipes/plugins you have configured via your remote account. By default, this checks for updates every five minutes.

Configuration

All pollers can be configured to use different polling intervals by supplying the desired seconds in which to poll. You can do this by modifying each script. Example:

# bin/pollers/firmware
poller.call seconds: 21_600

# bin/pollers/model
poller.call seconds: 36_000

# bin/pollers/screen
poller.call seconds: 300

Each automatically runs in the background as separate processes in both the Procfile and Procfile.dev files. The latter is built for you when running bin/setup.

Restarts

When using Overmind, you can restart these pollers (as with any process managed by Overmind) as follows:

overmind restart firmware_poller
overmind restart model_poller
overmind restart screen_poller

This can be handy if you want to force either of these poller’s to check for new content.

Disablement

To disable any of the pollers, use the following environment variables:

FIRMWARE_POLLER=0
MODEL_POLLER=0
SCREEN_POLLER=0

You are not limited to using 0. Any falsey value would work, example: false, "no", etc. When any of the pollers are disabled, you’ll see the following messages in your logs (where <poller> is replaced with the specific poller that is disabled):

<poller> polling disabled.

Firmware

By default, the Firmware Poller will automatically download the latest firmware but you’ll need to enable firmware updates for your device to have each new firmware release automatically applied. You can do this by editing your device and clicking the Firmware Update checkbox to enable. Otherwise, newer firmware versions will be cached on the server but your device won’t update.

In situations where your device updated to a newer Firmware version and it was a bad/broken version, you can revert to and older version by following these steps:

  1. Ensure the device you want to downgrade has firmware updates turned on (you’ll also want to ensure devices you don’t want to downgrade have this setting turned off).

  2. Visit the Firmwares page within the UI.

  3. Delete all latest versions until you only have the version you want to downgrade to listed at the top of the list.

  4. Short click the button on the back of your device to force a refresh and wait for the firmware downgrade to complete.

  5. That’s it!

APIs

Each API endpoint uses HTTPS which requires accepting the locally generated SSL certificate by this application when running the Ruby stack locally. If you don’t want this behavior, you can switch to using HTTP (see above). For Docker, at the moment, none of this applies so can only use HTTP requests.

See each category/endpoint, below, for further details.

Firmware

The following endpoints are used to communicate with your device’s TRMNL Firmware. These endpoints typically require authentication via the HTTP ID header which is your device’s MAC address. Any changes to these endpoints require updates to both the firmware and this implementation so they don’t change often.

Display

Used for displaying new content to your device. Your device’s refresh rate determines how often this occurs.

Request

Without Base64 Encryption

curl "https://localhost:2443/api/display" \
     -H 'ID: <redacted>' \
     -H 'Content-Type: application/json'

With Base64 Encryption via HTTP Header

curl "https://localhost:2443/api/display" \
     -H 'ID: <redacted>' \
     -H 'Content-Type: application/json' \
     -H 'BASE64: true'

With Base64 Encryption via Parameter

curl "https://localhost:2443/api/display?base_64=true" \
     -H 'ID: <redacted>' \
     -H 'Content-Type: application/json'

Only the ID HTTP header is required for all of these API calls but these optional headers can be supplied as well which mimics what each device includes each request:

  • ACCESS_TOKEN: Can be the API key or an empty string.

  • BATTERY_VOLTAGE: Must a a float (usually 0.0 to 4.1).

  • FW_VERSION: The firmware version (i.e. 1.2.3).

  • HOST: The host (usually the IP address).

  • REFRESH_RATE: The refresh rate as saved on the device. Example: 100.

  • RSSI: The signal strength (usually -100 to 100).

  • USER_AGENT: The device name.

  • WIDTH: The device width. Example: 800.

  • HEIGHT: :The device height. Example: 480.

Response

Without Base64 Encryption

{
  "filename": "demo.bmp",
  "firmware_url": "http://localhost:2443/assets/firmware/1.4.8.bin",
  "image_url": "https://localhost:2443/assets/screens/A1B2C3D4E5F6/demo.bmp",
  "image_url_timeout": 0,
  "refresh_rate": 130,
  "reset_firmware": false,
  "special_function": "sleep",
  "update_firmware": false
}

With Base64 Encryption

{
  "filename": "demo.bmp",
  "firmware_url": "http://localhost:2443/assets/firmware/1.4.8.bin",
  "image_url": "data:image/bmp;base64,<truncated>",
  "image_url_timeout": 0,
  "refresh_rate": 200,
  "reset_firmware": false,
  "special_function": "sleep",
  "update_firmware": false
}
Log

Used by device firmware to log information about your device. Mostly used for debugging purposes. You can find device specific logs within the UI by clicking on your device logs.

Request
## Log
curl -X "POST" "https://localhost:2443/api/log" \
     -H 'ID: <redacted>' \
     -H 'Content-Type: application/json' \
     -d $'{
  "logs": [
    {
      "id": 666,
      "message": "An API test.",
      "wifi_status": "connected",
      "created_at": 1742022124,
      "sleep_duration": 31,
      "refresh_rate": 30,
      "free_heap_size": 160656,
      "max_alloc_size": 180000,
      "source_path": "src/bl.cpp",
      "wake_reason": "timer",
      "firmware_version": "1.5.2",
      "retry": 1,
      "battery_voltage": 4.772,
      "source_line": 597,
      "special_function": "none",
      "wifi_signal": -54
    }
  ]
}'
Response

Logs details and answers a HTTP 204 status with no content.

Setup

Used for new device setup and then never used after.

Request
curl "https://localhost:2443/api/setup/" \
     -H 'ID: <redacted>' \
     -H 'Content-Type: application/json'
Response
{
  "api_key": "<redacted>",
  "friendly_id": "ABC123",
  "image_url": "https://localhost:2443/assets/setup.bmp",
  "message": "Welcome to TRMNL BYOS"
}

Server

⚠️ These endpoints are constantly evolving and we will do our best to minimize impact but please be aware there might be action on your part when new changes are released.

The following endpoints are unique to this server implementation and allow you to interact via your favorite JSON Data API client. Most of these endpoints require an HTTP Access-Token header which is your device’s API key.

These endpoints are faster to change/update since they won’t break any communication with your device. Any/all error responses adhere to RFC 9457 (Problem Details for HTTP APIs) as implemented by the Petail gem which also means you can use Petail to easily parse the error responses in your own application if desired.

Lastly, these endpoints are constantly evolving and not entirely stable as of yet.

Devices

Allows you to manage your devices.

GET Request
# All devices.
curl "https://localhost:2443/api/devices" \
     -H 'Content-Type: application/json'

# Single device.
curl "https://localhost:2443/api/devices/1" \
     -H 'Content-Type: application/json'
GET Response
# All devices.
{
  "data": [
    {
      "id": 1,
      "model_id": 1,
      "playlist_id": 1,
      "friendly_id": "DEMO11",
      "label": "Demo",
      "mac_address": "A1:B2:C3:D4:E5:F6",
      "api_key": "OScdcN0kFbKjFcid9Kz6Cx",
      "firmware_version": "1.5.12",
      "firmware_beta": false,
      "wifi": -71,
      "battery": 4.0,
      "refresh_rate": 500,
      "image_timeout": 0,
      "width": 800,
      "height": 480,
      "proxy": true,
      "firmware_update": true,
      "sleep_start_at": "22:30:00",
      "sleep_stop_at": "05:30:00",
      "created_at": "2025-07-29T16:19:05+0000",
      "updated_at": "2025-07-29T16:19:05+0000"
    }
  ]
}

# Single device.
{
  "data": {
    "id": 1,
    "model_id": 1,
    "playlist_id": 1,
    "friendly_id": "DEMO11",
    "label": "Demo",
    "mac_address": "A1:B2:C3:D4:E5:F6",
    "api_key": "OScdcN0kFbKjFcid9Kz6Cx",
    "firmware_version": "1.5.12",
    "firmware_beta": false,
    "wifi": -71,
    "battery": 4.0,
    "refresh_rate": 500,
    "image_timeout": 0,
    "width": 800,
    "height": 480,
    "proxy": true,
    "firmware_update": true,
    "sleep_start_at": "22:30:00",
    "sleep_stop_at": "05:30:00",
    "created_at": "2025-07-29T16:19:05+0000",
    "updated_at": "2025-07-29T16:19:05+0000"
  }
}

You’ll get an empty array when no devices exist.

POST Request
# Minimum
curl -X "POST" "https://localhost:2443/api/devices" \
     -H 'Content-Type: application/json' \
     -d $'{
  "device": {
    "label": "Demo",
    "model_id": 1,
    "mac_address": "A1:B2:C3:D4:E5:F6"
  }
}'
# Maximum (all possible fields)
curl -X "POST" "https://localhost:2443/api/devices" \
     -H 'Content-Type: application/json' \
     -d $'{
  "device": {
    "model_id": 1,
    "playlist_id": null,
    "label": "Demo",
    "friendly_id": "DEMO11",
    "mac_address": "A1:B2:C3:D4:E5:F6",
    "api_key": "OScdcN0kFbKjFcid9Kz6Cx",
    "refresh_rate": "500",
    "image_timeout": "0",
    "firmware_beta": false,
    "firmware_update": true,
    "proxy": true,
    "sleep_start_at": "23:00:00",
    "sleep_stop_at": "06:00:00"
  }
}'
POST Response
{
  "data": {
    "id": 1,
    "model_id": 1,
    "playlist_id": 1,
    "friendly_id": "DEMO11",
    "label": "Demo",
    "mac_address": "A1:B2:C3:D4:E5:F6",
    "api_key": "OScdcN0kFbKjFcid9Kz6Cx",
    "firmware_version": "1.5.12",
    "firmware_beta": false,
    "wifi": -71,
    "battery": 4.0,
    "refresh_rate": 500,
    "image_timeout": 0,
    "width": 800,
    "height": 480,
    "proxy": true,
    "firmware_update": true,
    "sleep_start_at": "22:30:00",
    "sleep_stop_at": "05:30:00",
    "created_at": "2025-07-29T16:19:05+0000",
    "updated_at": "2025-07-29T16:19:05+0000"
  }
}
PATCH Request
## Devices (patch)
curl -X "PATCH" "https://localhost:2443/api/devices/1" \
     -H 'Content-Type: application/json' \
     -d $'{
  "device": {
    "refresh_rate": 250
  }
}'

You you change a single attribute or multiple attributes at once.

PATCH Response
{
  "data": {
    "id": 1,
    "model_id": 1,
    "playlist_id": 3,
    "friendly_id": "DEMO11",
    "label": "Demo",
    "mac_address": "A1:B2:C3:D4:E5:F6",
    "api_key": "OScdcN0kFbKjFcid9Kz6Cx",
    "firmware_version": "1.5.12",
    "firmware_beta": false,
    "wifi": -71,
    "battery": 4.0,
    "refresh_rate": 250,
    "image_timeout": 0,
    "width": 800,
    "height": 480,
    "proxy": true,
    "firmware_update": true,
    "sleep_start_at": "22:30:00",
    "sleep_stop_at": "05:30:00",
    "created_at": "2025-07-29T16:19:05+0000",
    "updated_at": "2025-07-29T16:19:05+0000"
  }
}
DELETE Request
## Devices (delete)
curl -X "DELETE" "https://localhost:2443/api/devices/1" \
     -H 'Content-Type: application/json' \
     -d $'{}'
DELETE Response
{
  "data": {
    "id": 1,
    "model_id": 1,
    "playlist_id": 3,
    "friendly_id": "DEMO11",
    "label": "Demo",
    "mac_address": "A1:B2:C3:D4:E5:F6",
    "api_key": "OScdcN0kFbKjFcid9Kz6Cx",
    "firmware_version": "1.5.12",
    "firmware_beta": false,
    "wifi": -71,
    "battery": 4.0,
    "refresh_rate": 500,
    "image_timeout": 0,
    "width": 800,
    "height": 480,
    "proxy": true,
    "firmware_update": true,
    "sleep_start_at": "22:30:00",
    "sleep_stop_at": "05:30:00",
    "created_at": "2025-07-29T16:19:05+0000",
    "updated_at": "2025-07-29T16:19:05+0000"
  }
}

You’ll get an empty hash when there is nothing to delete.

Models

Provides model information which is the core specfication for devices and screens. Models are also differentiated by kind which can be:

  • core: Originates from our Core server and is part of the synchronization process provided by the model Background Poller.

  • terminus: Originates from Terminus only.

GET Request
# All models.
curl "https://localhost:2443/api/models" \
     -H 'Content-Type: application/json'

# Single model.
curl "https://localhost:2443/api/models/1" \
     -H 'Content-Type: application/json'
GET Response
# All models.
{
  "data": [
    {
      "id": 1,
      "name": "og_png",
      "label": "TRMNL (1-bit)",
      "description": "TRMNL (1-bit)",
      "kind": "core",
      "mime_type": "image/png",
      "colors": 2,
      "bit_depth": 1,
      "scale_factor": 1.0,
      "rotation": 0,
      "offset_x": 0,
      "offset_y": 0,
      "width": 800,
      "height": 480,
      "published_at": "2024-01-01T00:00:00+0000",
      "created_at": "2025-08-13T19:34:08+0000",
      "updated_at": "2025-08-13T19:34:08+0000"
    }
  ]
}

# Single model.
{
  "data": {
    "id": 1,
    "name": "og_png",
    "label": "TRMNL (1-bit)",
    "description": "TRMNL (1-bit)",
    "kind": "core",
    "mime_type": "image/png",
    "colors": 2,
    "bit_depth": 1,
    "scale_factor": 1.0,
    "rotation": 0,
    "offset_x": 0,
    "offset_y": 0,
    "width": 800,
    "height": 480,
    "published_at": "2024-01-01T00:00:00+0000",
    "created_at": "2025-08-13T19:34:08+0000",
    "updated_at": "2025-08-13T19:34:08+0000"
  }
}

You’ll get an empty array when no models exist.

POST Request
curl -X "POST" "https://localhost:2443/api/models" \
     -H 'Content-Type: application/json' \
     -d $'{
  "model": {
    "name": "demo",
    "label": "Demo",
    "description": "A demonstration.",
    "kind": "core",
    "rotation": 25,
    "width": "1000",
    "colors": 4,
    "mime_type": "image/png",
    "scale_factor": 2,
    "bit_depth": 2,
    "offset_x": 50,
    "height": "500",
    "offset_y": 100,
    "published_at": "2025-01-01T00:00:00+00:00",
    "created_at": "2025-08-13T19:34:08+0000",
    "updated_at": "2025-08-13T19:34:08+0000"
  }
}'
POST Response
{
  "data": {
    "id": 12,
    "name": "demo",
    "label": "Demo",
    "description": "A demonstration.",
    "kind": "core",
    "mime_type": "image/png",
    "colors": 4,
    "bit_depth": 2,
    "scale_factor": 2.0,
    "rotation": 25,
    "offset_x": 50,
    "offset_y": 100,
    "width": 1000,
    "height": 500,
    "published_at": "2025-01-01T00:00:00+0000",
    "created_at": "2025-08-13T19:42:06+0000",
    "updated_at": "2025-08-13T19:42:06+0000"
  }
}
PATCH Request
curl -X "PUT" "https://localhost:2443/api/models/12" \
     -H 'Content-Type: application/json' \
     -d $'{
  "model": {
    "description": "A patch demonstration."
  }
}'

You you change a single attribute or multiple attributes at once.

PATCH Response
{
  "data": {
    "id": 12,
    "name": "demo",
    "label": "Demo",
    "description": "A patch demonstration.",
    "kind": "core",
    "mime_type": "image/png",
    "colors": 4,
    "bit_depth": 2,
    "scale_factor": 2.0,
    "rotation": 25,
    "offset_x": 50,
    "offset_y": 100,
    "width": 1000,
    "height": 500,
    "published_at": "2025-01-01T00:00:00+0000",
    "created_at": "2025-08-13T19:42:06+0000",
    "updated_at": "2025-08-13T19:42:31+0000"
  }
}
DELETE Request
curl -X "DELETE" "https://localhost:2443/api/models/2" \
     -H 'Content-Type: application/json' \
     -d $'{}'
DELETE Response
{
  "data": {
    "id": 12,
    "name": "demo",
    "label": "Demo",
    "description": "A patch demonstration.",
    "kind": "core",
    "width": 1000,
    "height": 500,
    "published_at": "2025-01-01 00:00:00 UTC",
    "mime_type": "image/png",
    "colors": 4,
    "bit_depth": 2,
    "scale_factor": 2.0,
    "rotation": 25,
    "offset_x": 50,
    "offset_y": 100,
    "created_at": "2025-08-13 19:42:06 UTC",
    "updated_at": "2025-08-13 19:42:31 UTC"
  }
}

You’ll get an empty hash when there is nothing to delete.

Screens

Used for generating new device screens by supplying HTML content for rendering, screenshotting, and grey scaling to render properly on your device. Both .png or .bmp extensions are supported for the file_name key. If you don’t supply a file_name, the server will generate one for you using a UUID for the file name. You can find all generated images in public/assets/screens.

When making requests, the Access-Token is your device’s API key. You can obtain this information from within the UI for your specific device.

GET Request
curl "https://localhost:2443/api/screens" \
     -H 'Content-Type: application/json'
GET Response
{
  "data": [
    {
      "id": 3,
      "model_id": 1,
      "label": "Moon",
      "name": "plugin-6e6740",
      "created_at": "2025-07-29T16:29:02+0000",
      "updated_at": "2025-07-29T16:29:02+0000",
      "filename": "plugin-6e6740.bmp",
      "mime_type": "image/bmp",
      "bit_depth": 1,
      "width": 800,
      "height": 480,
      "size": 48062,
      "uri": "/uploads/9fb384c1aa9043ccfee781ab21d99aa0.bmp"
    }
  ]
}
POST Request (HTML Content)
curl -X "POST" "https://localhost:2443/api/screens" \
     -H 'Content-Type: application/json' \
     -d $'{
  "image": {
    "label": "Demo",
    "content": "<h1>Demo</h1>",
    "name": "demo",
    "file_name": "demo.png",
    "model_id": "1"
  }
}'

Allows you to render custom HTML content as an image on your device. Full HTML is supported so you can supply CSS styles, full DOM, etc. At a minimum, you’ll want to use the following to prevent white borders showing up around your generated screens:

* {
  margin: 0;
}

Here’s a more complete example using CSS and HTML that you can supply via the API request:

<!DOCTYPE html>

<html lang="en">
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">

    <title>Demo</title>

    <meta charset="utf-8">

    <style type="text/css">
      * {
        margin: 0;
      }
    </style>

    <script type="text/javascript">
    </script>
  </head>

  <body>
    <img src="uri/to/image" alt="Image"/>
  </body>
</html>

Due to this being so flexible, this also means you can pull in and use our Design Framework when building custom screens by linking to the following:

đź’ˇ You can use the Designer within the UI to build custom screens in real-time for faster feedback. The result of your work can be supplied to this endpoint to create a new screen for display on your device.

POST Request (Preprocessed URI)
curl -X "POST" "https://localhost:2443/api/screens" \
     -H 'Content-Type: application/json' \
     -d $'{
  "image": {
    "file_name": "demo.png",
    "label": "Demo",
    "preprocessed": true,
    "name": "demo",
    "uri": "https://leonardo.ai/wp-content/uploads/2023/07/image-131.jpeg",
    "model_id": "1"
  }
}'

Allows you to render a preprocessed image — by you — on your device. The preprocessed value must be true when supplied. This assumes the URI you want have rendered on your device is fully compatible with the device in terms of greyscale, bit depth, color depth, and so forth. We do not process this image and only cache the image locally for display on your device. I you want want your image processed then see the Unprocessed URI API Request example.

POST Request (Unprocessed URI)
curl -X "POST" "https://localhost:2443/api/screens" \
     -H 'Content-Type: application/json' \
     -d $'{
  "image": {
    "file_name": "demo.png",
    "label": "Demo",
    "name": "demo",
    "uri": "https://leonardo.ai/wp-content/uploads/2023/07/image-131.jpeg",
    "model_id": "1"
  }
}'

Allows you to render a unprocessed image on your device. We’ll automatically process the image for rendering on your device. The dimensions parameter is optional and defaults to 800x480 when not supplied. You can use the full ImageMagick Geometry syntax as the value.

POST Request (Base64 Encoded Data)
curl -X "POST" "https://localhost:2443/api/screens" \
     -H 'Content-Type: application/json' \
     -d $'{
  "image": {
    "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAAXNSR0IArs4c6QAAAAtJREFUCFtjYGAAAAADAAHc7H1IAAAAAElFTkSuQmCC",
    "label": "Demo",
    "name": "demo",
    "file_name": "demo.png",
    "model_id": "1"
  }
}'

Allows you to render a strictly Base64 encoded image on your device. The dimensions parameter is optional and defaults to 800x480 when not supplied. You can use the full ImageMagick Geometry syntax as the value.

POST Response
{
  "data": {
    "id": 13,
    "model_id": 1,
    "label": "Demo",
    "name": "demo",
    "created_at": "2025-07-29T19:06:04+0000",
    "updated_at": "2025-07-29T19:06:04+0000",
    "filename": "demo.png",
    "mime_type": "image/png",
    "bit_depth": 1,
    "width": 800,
    "height": 480,
    "size": 415,
    "uri": "/uploads/619415a00830fa630649706977e95302.png"
  }
}

No matter what parameters you use for this request, you’ll always get a path (unless an error is encountered).

PATCH Request
curl -X "PATCH" "https://localhost:2443/api/screens/1" \
     -H 'Content-Type: application/json' \
     -d $'{
  "image": {
    "model_id": "1"
    "label": "Demo",
    "name": "demo",
    "content": "<h1>Demo</h1>"
  }
}'

Unlike the POST request, only HTML content is supported at the moment.

All of the above attributes are optional which means you can update only the attribute you care about or multiple attributes at once. At least one attribute must be supplied, though.

PATCH Response
{
  "data": {
    "id": 1,
    "model_id": 1,
    "label": "Demo",
    "name": "demo",
    "created_at": "2025-08-12T19:23:17+0000",
    "updated_at": "2025-08-12T19:23:17+0000",
    "filename": "demo.png",
    "mime_type": "image/png",
    "bit_depth": 1,
    "width": 800,
    "height": 480,
    "size": 462,
    "uri": "/uploads/71d0f2ec47b861ea8fe7807705d6e87b.png"
  }
}
DELETE Request
curl -X "DELETE" "https://localhost:2443/api/screens/13" \
     -H 'Content-Type: application/json'
DELETE Response
{
  "data": {
    "id": 13,
    "model_id": 1,
    "label": "Demo",
    "name": "demo",
    "created_at": "2025-07-29T19:11:04+0000",
    "updated_at": "2025-07-29T19:11:04+0000",
    "filename": "demo.png",
    "mime_type": "image/png",
    "bit_depth": 1,
    "width": 800,
    "height": 480,
    "size": 126,
    "uri": "/uploads/e27dc53657963e5ad765fdc246e60a3c.png"
  }
}

Development

To contribute, run:

git clone https://github.com/usetrmnl/byos_hanami terminus
cd terminus
bin/setup

Console

To access the console with direct access to all objects, run:

bin/console

Once in the console, you can interact with all objects. A few examples:

# Use a repository.
repository = Hanami.app["repositories.device"]

repository.all              # View all devices.
device = repository.find 1  # Find by Device ID.

YJIT

YJIT is enabled by default if detected which means you have built and installed Ruby with YJIT enabled. If you didn’t build Ruby with YJIT support, YJIT support will be ignored. That said, we recommend you enable YJIT support since the performance improvements are worth it.

đź’ˇ To enable YJIT globally, ensure the --yjit flag is added to your RUBYOPT environment variable. Example: export RUBYOPT="--yjit".

CSS

Pure CSS is used in order to avoid pulling in complicated frameworks. The following stylesheets allow you to customize the look and feel of this application as follows:

  • Settings: Use to customize site settings.

  • Colors: Use to customize site colors.

  • Keyframes: Use to customize keyframe behavior.

  • View Transitions: Use to customize view transitions.

  • Defaults: Use to customize HTML element defaults.

  • Layout: Use to customize the site layout.

  • Components: Use to customize general site components.

  • Dashboard: Use to customize the dashboard page.

  • Devices: Use to customize the devices page.

  • Designer: Use to customize the designer page.

For responsive resolutions, the following measurements are used:

  • Extra Small: 300px

  • Small: 500px

  • Medium: 825px

  • Large: 1000px

  • Extra Large: 1500px

HTML/CSS Sanitization

The Santize gem is used to sanitize HTML/CSS when using the console, API, or UI. All of this configured via the Terminus::Sanitizer class which defaults to the Sanitize::Config::RELAXED style with additional support for style and source elements. If you find elements being stripped from your HTML/CSS content, this is why. Feel free to open an issue if you need additional support.

Logging

By default, all logging is set to INFO level but you can get more verbose information by using the DEBUG level. There are multiple ways to do this. First, you can export the desired debug level:

export LOG_LEVEL=debug

You can also specify the log level before launching the server:

LOG_LEVEL=debug overmind start --port-step 10 --procfile Procfile.dev --can-die assets,migrate

Finally, you can configure the app to use a different log level via lib/terminus/lib_container.rb by adjusting log level of logger during registration:

register(:logger) { Cogger.new id: :terminus, level: :debug, formatter: :detail }

đź’ˇ See the Cogger gem documentation for further details.

Tests

To test, run:

bin/rake

Code Coverage

SimpleCov code coverage reports are generated with every Circle CI build. The badge at the top of this document isn’t updated in real-time, unfortunately, but fairly accurate since this project is configured to strive for 100% code coverage.

To view up-to-date details, follow these steps:

  1. Visit the Circle CI build page.

  2. Click on the latest "Success" build at the top of the page.

  3. Click on build.

  4. Click on ARTIFACTS.

  5. Click on the coverage/index.html file.

At this point you can click through the tabs at the top of the page to inspect the various namespaces that make up this application.

Docker

We provide Docker support in case you don’t want to use our Ruby stack. Both production and development environments are supported. In most cases, you’ll want to use Docker Compose to manage the stack. We also build Docker images for AMD 64 and ARM 64.

Continue reading to learn more.

Setup

Please ensure your have read and followed all Setup steps.

Configuration

Please ensure your have read and customized (optional) your Configuration as necessary.

Compose

You can use Docker Compose to quickly launch the entire stack for development or production environments.

To start, you’ll want to customize your API_URI environment variable so the URI points to the server from where you are running the full stack. This is important because the API IP address shown via the Dashboard page will only show the URI of your Docker image/container which devices can’t connect to. You can fix by adding updating your HANAMI_PORT and API_URI in the environment section. Here’s a few examples:

# With specific IP address.
environment:
  HANAMI_PORT=2300
  API_URI: http://192.168.1.1:$HANAMI_PORT

# With hostname.
environment:
  API_URI: https://terminus.demo.io

You can also confirm the above changes are applied by running docker-compose up and viewing the Dashboard (look for the API IP address).

Further details can be found in the compose.yml or compose.dev.yml files at the root of this project.

Development

To develop with Docker, you can use the following scripts:

  • bin/docker/up: Use to start up all services via Docker Compose.

  • bin/docker/down: Use to shut down all services via Docker Compose.

  • bin/docker/compose: Use to run any Docker Compose command.

  • bin/docker/entrypoint-dev: Used by compose.dev.yml to ensure the web service is setup properly.

Production

The following commands can be helpful when managing the stack locally:

  • docker-compose up: Builds and launches the entire stack.

  • docker-compose build web: Rebuilds the web service. You’ll want to run this before running up in order to pick up the latest changes whenever there is a new version release or pulling changes from the main branch.

  • docker-compose exec web bash: This’ll give you a Bash shell within root of the project. Use bin/console to launch a Hanami console.

  • docker logs terminus-web-1: Use this to view the web service logs.

If you only care about the web image, then you can use the Dockerfile and bin/docker scripts. Here’s how each works:

  • bin/docker/build: This will build a production Docker image based on latest changes to this project.

  • bin/docker/console: This will immediately give you a console for which to explore you Docker image from the command line.

  • bin/docker/entrypoint: This is used by the Dockerfile when building your Docker image.

If you don’t care about using Docker or Docker Compose locally, then you can use the prebuilt image since an image is built each time changes are applied to the main branch or a new tag is created. All images can be found in the Container Registry. Use as follows:

# Latest
docker pull ghcr.io/usetrmnl/terminus:latest

# Specific version.
docker pull ghcr.io/usetrmnl/terminus:<version>

You can also update compose.yml to use the above image by replacing the following (should you not want to manually build the image):

build:
  context: .

…​with:

image: ghcr.io/usetrmnl/terminus:latest

Devices

Once this server is up and running, you’ll want to connect your TRMNL device(s). The following guides will help you get started but are written for connecting to our Core server, not this server. When the docs say to Connect, make sure you fill in the API Server details (i.e. the API_URI as mentioned in the Configuration section) before connecting.

  • How to set up a new device.

  • Dealing with tricky Wi-Fi situations.

  • When switching servers, you’ll need to reset the device to connect to the new server. Do this by pressing and holding the button the back of the device for five seconds and then releasing to cause the device to reconnect. Once you connect to the TRMNL Captive Portal, click on the Soft Reset button to force the device to reset. Once reset, connect to the TRMNL Captive Portal one last time to fill in your API Server details and then click the Connect button to finally connect to your server.

Tools

The following is additional tooling, developed by the Open Source community, that might be of interest for use with this application:

  • Terminus Publisher: Provides a way to generate and publish content to Terminus for display on your device.

License

While this project is distributed under the permissive MIT License, we strongly believe that technology should serve humanity’s best interests. We created this software with the intent that it be used to benefit people and communities, not to cause harm. We encourage individuals and organizations to consider the ethical implications and to use this project in ways that respect human rights, promote equity, and contribute positively to society. Though we cannot legally restrict usage under the MIT License, we ask that you join us in fostering a responsible technology ecosystem by avoiding applications that could cause harm, perpetuate discrimination, or undermine human dignity. Technology is best used to enrich lives, let’s ensure we build a better world together!

Credits