Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions trmnl-ha/DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -360,11 +360,17 @@ The add-on supports multiple webhook payload formats for different e-ink display
| Format | Use Case |
|--------|----------|
| **Raw** (default) | TRMNL devices, custom endpoints |
| **BYOS Hanami** | Self-hosted [BYOS](https://github.com/usetrmnl/byos) servers |
| **BYOS Hanami** | Self-hosted [Terminus / BYOS Hanami](https://github.com/usetrmnl/terminus) servers |

For BYOS Hanami, the add-on offers two **delivery modes**:

- **URI mode** (recommended, required for Terminus ≥ 0.52.0): the add-on sends a URL; Terminus fetches the image itself. Set **Add-on URL** to an address **Terminus** can reach — often *not* `localhost`. See the guide for deployment topologies.
- **Legacy base64 mode**: inlines the image in the JSON body. Only works on Terminus ≤ 0.51.0.

See **[Webhook Formats Guide](docs/webhook-formats.md)** for:
- Detailed format specifications
- Detailed format specifications and delivery mode selection
- JWT authentication setup for BYOS
- Deployment topology examples for the Add-on URL (Docker, LAN, reverse proxy)
- How to add custom webhook formats

---
Expand Down
114 changes: 106 additions & 8 deletions trmnl-ha/docs/webhook-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ When uploading screenshots via webhooks, the add-on supports multiple payload fo
| Format | Content-Type | Use Case |
|--------|--------------|----------|
| **Raw** (default) | `image/png`, `image/jpeg`, `image/bmp` | Direct binary upload to TRMNL or custom endpoints |
| **BYOS Hanami** | `application/json` | Self-hosted [BYOS](https://github.com/usetrmnl/byos) servers |
| **BYOS Hanami** | `application/json` | Self-hosted [Terminus / BYOS Hanami](https://github.com/usetrmnl/terminus) servers |

---

Expand All @@ -30,7 +30,85 @@ Authorization: Bearer <optional-token>

## BYOS Hanami Format

For self-hosted [BYOS (Build Your Own Server)](https://github.com/usetrmnl/byos) installations, this format wraps the image in a JSON payload with metadata.
For self-hosted [Terminus / BYOS Hanami](https://github.com/usetrmnl/terminus) installations, this format wraps the screen metadata in a JSON payload and delivers the image to `POST /api/screens`.

### Delivery Modes

Terminus supports two incompatible payload shapes depending on its version. The add-on exposes both via a **Delivery Mode** dropdown in the schedule UI.

| Mode | Terminus versions | Image transport |
|------|-------------------|-----------------|
| **URI** (recommended) | `≥ 0.11.0`, required from `0.52.0` onward | Terminus fetches the image from the add-on over HTTP |
| **Legacy base64** | `≤ 0.51.0` only | Add-on inlines the image as base64 in the JSON body |

Base64 support was removed from Terminus in `0.52.0` (released 2026-04-01). New installations should pick **URI** mode; older deployments can stay on **Legacy base64** until they upgrade.

### URI Mode

In URI mode, the add-on sends a small JSON payload referencing a screenshot endpoint on the add-on itself. Terminus then calls back to that URL, downloads the dithered image, and stores it.

**Request:**
```http
POST /api/screens
Content-Type: application/json
Authorization: <jwt-access-token>

{
"screen": {
"uri": "http://192.168.1.100:10000/lovelace/0?viewport=800x480&dithering=&dither_method=floyd-steinberg&palette=gray-4",
"label": "Home Assistant",
"name": "ha-dashboard",
"model_id": "1",
"preprocessed": true
}
}
```

**Requirements:**

- `preprocessed: true` tells Terminus to use the image as-is without running its own dithering. The add-on always sends preprocessed images since it dithers locally.
- The add-on's screenshot endpoint must be reachable without authentication, or the Add-on URL must include any credentials Terminus needs.
- If URI mode is selected but **Add-on URL** is blank, the add-on throws a clear error at delivery time rather than silently falling back.

#### Setting the Add-on URL

> ⚠️ **This is the URL Terminus will use to reach the add-on — not the URL you use in your browser.**
>
> The Add-on URL field must resolve from *Terminus's* network vantage point, not yours. If Terminus runs in Docker on the same host, `http://localhost:10000` will **not** work — inside the Terminus container, `localhost` points at the container itself, and nothing is listening on port `10000` there.

Think of it as two asymmetric network hops:

```
You (browser) ──────▶ Add-on UI (your browser resolves "localhost")
Add-on ──────▶ Terminus (webhook URL, see "Webhook URL" field)
Terminus ──────▶ Add-on screenshot (Add-on URL, see this section)
```

The second and third hops resolve DNS from different vantage points, so the Webhook URL and the Add-on URL often need to be different strings even when both services are on the same physical machine.

Pick the value that matches your deployment topology:

| Where Terminus runs | Set Add-on URL to | Notes |
|---|---|---|
| Docker on the same host as the add-on (Docker Desktop on Mac/Windows) | `http://host.docker.internal:10000` | Docker Desktop's built-in DNS name for the host machine |
| Docker on the same Linux host | `http://172.17.0.1:10000` or your LAN IP | `172.17.0.1` is the default docker bridge gateway; LAN IP also works |
| A different machine on the same LAN | `http://<add-on-lan-ip>:10000` | Use the add-on host's routable IP, never `localhost` |
| Behind a reverse proxy / public URL | `https://trmnl.example.com` | Whatever public hostname forwards to the add-on's port 10000 |
| As a Home Assistant add-on, accessed via ingress | The add-on's ingress URL | See your HA installation's external ingress configuration |

**Verification shortcut.** Before retrying a failed schedule, shell into the Terminus container and confirm it can reach the add-on:

```sh
docker compose -p terminus-development exec web \
curl -sI http://host.docker.internal:10000/health
# Expected: HTTP/1.1 200 OK
```

If that curl fails, fix the URL before touching anything else — every downstream error (`ECONNREFUSED`, `improper image header` from MiniMagick, 500 from Terminus) traces back to this one setting.

### Legacy Base64 Mode

Legacy mode embeds the image directly in the JSON body. Keep it selected only if your Terminus is `≤ 0.51.0`.

**Request:**
```http
Expand All @@ -43,19 +121,32 @@ Authorization: <jwt-access-token>
"data": "<base64-encoded-image>",
"label": "Home Assistant",
"name": "ha-dashboard",
"model_id": "1"
"model_id": "1",
"file_name": "ha-dashboard.png",
"preprocessed": true
}
}
```

### Backward Compatibility

Schedules created before this feature shipped have no `delivery_mode` field in their stored config. The add-on treats them as follows:

- No `delivery_mode` + no Add-on URL → **Legacy base64** (preserves pre-existing behavior)
- No `delivery_mode` + Add-on URL configured → **URI**
- Explicit `delivery_mode: 'data'` → **Legacy base64** (user choice wins even if Add-on URL is set)
- Explicit `delivery_mode: 'uri'` → **URI** (throws if Add-on URL is missing)

### Configuration Fields

| Field | Description |
|-------|-------------|
| `label` | Display name shown in BYOS UI |
| `name` | Unique screen identifier (slug format) |
| `model_id` | BYOS device model ID (from your BYOS setup) |
| `preprocessed` | Whether the image is already optimized for e-ink |
| `preprocessed` | Whether the image is already optimized for e-ink (always `true` from the add-on) |
| `delivery_mode` | `'uri'` or `'data'`. Omitted on legacy schedules. |
| `addon_base_url` | URL of this add-on as reachable **from Terminus**, required for URI mode. See [Setting the Add-on URL](#setting-the-add-on-url). |

### JWT Authentication

Expand Down Expand Up @@ -107,8 +198,11 @@ export class YourFormatTransformer implements FormatTransformer {
imageBuffer: Buffer,
format: ImageFormat,
config?: YourFormatConfig,
screenshotUrl?: string,
): WebhookPayload {
// Transform the image buffer into your payload format
// Transform the image buffer into your payload format.
// `screenshotUrl` is only set for URI-mode formats (see BYOS Hanami).
// Formats that inline the image can ignore it.
return {
body: JSON.stringify({
image: imageBuffer.toString('base64'),
Expand Down Expand Up @@ -182,15 +276,19 @@ Schedule Execution
ScheduleExecutor.call()
uploadToWebhook(options)
#buildScreenshotUrl(schedule) ← URI mode only; undefined otherwise
getTransformer(webhookFormat) ← Strategy selection
uploadToWebhook(options)
transformer.transform(buffer, format, config)
getTransformer(webhookFormat) ← Strategy selection
transformer.transform(buffer, format, config, screenshotUrl)
↓ ← BYOS transformer branches on delivery_mode
fetch(webhookUrl, { body, headers })
```

For BYOS in URI mode, Terminus then performs a second round-trip back to the add-on's screenshot endpoint to download the actual image.

The transformer is responsible for:
- Converting the image buffer to the target payload format
- Setting the appropriate `Content-Type` header
Expand Down
20 changes: 20 additions & 0 deletions trmnl-ha/ha-trmnl/html/js/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ import type {
WebhookFormat,
WebhookFormatConfig,
ByosAuthConfig,
ByosDeliveryMode,
} from '../../types/domain.js'
import { BYOS_DEFAULT_DELIVERY_MODE } from '../../types/domain.js'

// =============================================================================
// FORM PARSING HELPERS
Expand Down Expand Up @@ -531,6 +533,9 @@ class App {
if (format === 'byos-hanami') {
const name = input('s_byos_name') || 'ha-dashboard'
const authEnabled = checkbox('s_byos_auth_enabled')
const rawDeliveryMode = select('s_byos_delivery_mode')
const deliveryMode: ByosDeliveryMode =
rawDeliveryMode === 'uri' ? 'uri' : BYOS_DEFAULT_DELIVERY_MODE

// Preserve existing auth tokens (managed by byosLogin/byosLogout, not form)
// If auth is enabled but no tokens yet, create empty auth object with enabled: true
Expand All @@ -544,6 +549,8 @@ class App {
name,
model_id: input('s_byos_model_id') || '1',
preprocessed: true,
delivery_mode: deliveryMode,
addon_base_url: input('s_byos_addon_url') || undefined,
auth: authEnabled ? (existingAuth ?? { enabled: true }) : undefined,
},
}
Expand Down Expand Up @@ -909,6 +916,19 @@ class App {
await this.updateScheduleFromForm()
}

/**
* Toggles BYOS delivery mode (URI vs legacy base64) and shows/hides the
* Add-on URL field which is only meaningful for URI mode.
*/
async toggleByosDeliveryMode(mode: string): Promise<void> {
const urlField = document.getElementById('s_byos_addon_url_field')
if (urlField) {
urlField.classList.toggle('hidden', mode !== 'uri')
}

await this.updateScheduleFromForm()
}

/**
* Authenticates with BYOS server. Credentials are NOT stored.
*/
Expand Down
10 changes: 10 additions & 0 deletions trmnl-ha/ha-trmnl/html/js/device-presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,16 @@ export class DevicePresetsManager {
heightInput.dispatchEvent(new Event('change'))
}

// Update crop dimensions to match device viewport
const cropWidth = document.getElementById('s_crop_width') as HTMLInputElement | null
const cropHeight = document.getElementById('s_crop_height') as HTMLInputElement | null
if (cropWidth && device.viewport?.width) {
cropWidth.value = device.viewport.width
}
if (cropHeight && device.viewport?.height) {
cropHeight.value = device.viewport.height
}

if (device.rotate) {
const rotateSelect = document.getElementById(
's_rotate',
Expand Down
21 changes: 21 additions & 0 deletions trmnl-ha/ha-trmnl/html/js/ui-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
*/

import type { Schedule } from '../../types/domain.js'
import { BYOS_DEFAULT_DELIVERY_MODE } from '../../types/domain.js'
import type { PaletteOption } from './palette-options.js'
import { buildScreenshotParams } from '../shared/build-screenshot-params.js'
import { resolveScreenshotTarget } from '../shared/screenshot-target.js'
Expand Down Expand Up @@ -306,6 +307,26 @@ export class RenderScheduleContent {
placeholder="1"
title="BYOS model ID for your device" />
</div>
<div>
<label class="block text-xs text-gray-600 mb-1">Delivery Mode</label>
<select id="s_byos_delivery_mode"
class="w-full px-2 py-1 text-sm border rounded-md" style="border-color: var(--primary-light)"
onchange="window.app.toggleByosDeliveryMode(this.value)"
title="How Terminus receives the screenshot">
<option value="data" ${(byosConfig?.delivery_mode ?? BYOS_DEFAULT_DELIVERY_MODE) === 'data' ? 'selected' : ''}>Legacy base64 (Terminus ≤ 0.51.0)</option>
<option value="uri" ${byosConfig?.delivery_mode === 'uri' ? 'selected' : ''}>URI (Terminus ≥ 0.52.0, recommended)</option>
</select>
<p class="text-xs text-gray-500 mt-1">Base64 was removed in Terminus 0.52.0. Pick URI if you're on 0.52.0 or newer.</p>
</div>
<div id="s_byos_addon_url_field" class="${(byosConfig?.delivery_mode ?? BYOS_DEFAULT_DELIVERY_MODE) === 'uri' ? '' : 'hidden'}">
<label class="block text-xs text-gray-600 mb-1">Add-on URL</label>
<input type="text" id="s_byos_addon_url" value="${byosConfig?.addon_base_url || ''}"
class="w-full px-2 py-1 text-sm border rounded-md" style="border-color: var(--primary-light)"
onchange="window.app.updateScheduleFromForm()"
placeholder="http://192.168.1.100:10000"
title="External URL of this add-on (required for Terminus to fetch screenshots)" />
<p class="text-xs text-gray-500 mt-1">Terminus fetches screenshots from this URL</p>
</div>
</div>

<!-- JWT Authentication -->
Expand Down
25 changes: 25 additions & 0 deletions trmnl-ha/ha-trmnl/lib/scheduler/schedule-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
uploadToWebhook,
buildParams,
} from './services.js'
import { resolveScreenshotTarget } from '../../html/shared/screenshot-target.js'
import { buildScreenshotParams } from '../../html/shared/build-screenshot-params.js'
import {
SCHEDULER_MAX_RETRIES,
SCHEDULER_RETRY_DELAY_MS,
Expand Down Expand Up @@ -134,6 +136,27 @@ export class ScheduleExecutor {
return outputPath
}

/** Builds the screenshot endpoint URL for BYOS URI mode. Returns undefined when URI mode is not applicable. */
#buildScreenshotUrl(schedule: Schedule): string | undefined {
const byos = schedule.webhook_format?.byosConfig
if (!byos?.addon_base_url) return undefined
if (byos.delivery_mode === 'data') return undefined

const target = resolveScreenshotTarget(schedule)
const params = buildScreenshotParams(schedule)

// NOTE: In generic mode the router serves the UI at `/` unless `url` is
// present in the query. Thread target_url through so Terminus's fetch
// hits the screenshot handler instead of the UI HTML.
if (!target.isHAMode && target.fullUrl) {
params.append('url', target.fullUrl)
}

const path = target.path.replace(/^\//, '')

return `${byos.addon_base_url.replace(/\/+$/, '')}/${path}?${params.toString()}`
}

/** Uploads to webhook if configured, returns result for UI feedback */
async #uploadIfConfigured(
schedule: Schedule,
Expand All @@ -143,6 +166,7 @@ export class ScheduleExecutor {
if (!schedule.webhook_url) return undefined

const webhookUrl = schedule.webhook_url
const screenshotUrl = this.#buildScreenshotUrl(schedule)

try {
const result = await uploadToWebhook({
Expand All @@ -151,6 +175,7 @@ export class ScheduleExecutor {
imageBuffer,
format: format as 'png' | 'jpeg' | 'bmp',
webhookFormat: schedule.webhook_format,
screenshotUrl,
onTokenRefresh: (newTokens) => {
const byosConfig = schedule.webhook_format?.byosConfig
if (!byosConfig?.auth) return
Expand Down
4 changes: 4 additions & 0 deletions trmnl-ha/ha-trmnl/lib/scheduler/webhook-delivery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ export interface WebhookDeliveryOptions {
format: ImageFormat
/** Webhook payload format (null/undefined = 'raw' for backward compat) */
webhookFormat?: WebhookFormatConfig | null
/** Screenshot URL for BYOS URI mode (Terminus fetches from this URL) */
screenshotUrl?: string
/** Callback to persist refreshed BYOS JWT tokens */
onTokenRefresh?: (newTokens: TokenResponse) => void
}
Expand Down Expand Up @@ -147,6 +149,7 @@ export async function uploadToWebhook(
imageBuffer,
format,
webhookFormat,
screenshotUrl,
onTokenRefresh,
} = options

Expand All @@ -160,6 +163,7 @@ export async function uploadToWebhook(
imageBuffer,
format,
byosConfig,
screenshotUrl,
)

log.info`Sending webhook: ${webhookUrl} (${contentType}, ${imageBuffer.length} bytes, format: ${webhookFormat?.format ?? 'raw'})`
Expand Down
Loading
Loading