Skip to content

Commit 9563b49

Browse files
ikraamgclaude
andcommitted
Added BYOS Hanami URI delivery mode
Terminus 0.52.0 removed base64 payload support, so the add-on needs a way to hand Terminus a URL it can fetch. URI mode sends the add-on's screenshot endpoint URL and lets Terminus pull the image itself. Legacy base64 mode is preserved as an opt-in for Terminus ≤ 0.51.0 users. A delivery-mode selector in the BYOS schedule config defaults to `data` so existing schedules keep working unchanged until users opt in to URI mode. Includes supporting pieces for the feature: - `addon_base_url` + `ByosDeliveryMode` added to the BYOS config - `BYOS_DEFAULT_DELIVERY_MODE` constant shared by UI and extractor - Screenshot URL builder threads `url` query param in generic mode so Terminus fetches hit the screenshot handler, not the UI HTML - Device preset selection auto-fills crop dimensions from viewport - Webhook format tests restructured by mode (URI / data / explicit) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 15d8334 commit 9563b49

File tree

10 files changed

+407
-69
lines changed

10 files changed

+407
-69
lines changed

trmnl-ha/DOCS.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -360,11 +360,17 @@ The add-on supports multiple webhook payload formats for different e-ink display
360360
| Format | Use Case |
361361
|--------|----------|
362362
| **Raw** (default) | TRMNL devices, custom endpoints |
363-
| **BYOS Hanami** | Self-hosted [BYOS](https://github.com/usetrmnl/byos) servers |
363+
| **BYOS Hanami** | Self-hosted [Terminus / BYOS Hanami](https://github.com/usetrmnl/terminus) servers |
364+
365+
For BYOS Hanami, the add-on offers two **delivery modes**:
366+
367+
- **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.
368+
- **Legacy base64 mode**: inlines the image in the JSON body. Only works on Terminus ≤ 0.51.0.
364369

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

370376
---

trmnl-ha/docs/webhook-formats.md

Lines changed: 106 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ When uploading screenshots via webhooks, the add-on supports multiple payload fo
77
| Format | Content-Type | Use Case |
88
|--------|--------------|----------|
99
| **Raw** (default) | `image/png`, `image/jpeg`, `image/bmp` | Direct binary upload to TRMNL or custom endpoints |
10-
| **BYOS Hanami** | `application/json` | Self-hosted [BYOS](https://github.com/usetrmnl/byos) servers |
10+
| **BYOS Hanami** | `application/json` | Self-hosted [Terminus / BYOS Hanami](https://github.com/usetrmnl/terminus) servers |
1111

1212
---
1313

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

3131
## BYOS Hanami Format
3232

33-
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.
33+
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`.
34+
35+
### Delivery Modes
36+
37+
Terminus supports two incompatible payload shapes depending on its version. The add-on exposes both via a **Delivery Mode** dropdown in the schedule UI.
38+
39+
| Mode | Terminus versions | Image transport |
40+
|------|-------------------|-----------------|
41+
| **URI** (recommended) | `≥ 0.11.0`, required from `0.52.0` onward | Terminus fetches the image from the add-on over HTTP |
42+
| **Legacy base64** | `≤ 0.51.0` only | Add-on inlines the image as base64 in the JSON body |
43+
44+
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.
45+
46+
### URI Mode
47+
48+
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.
49+
50+
**Request:**
51+
```http
52+
POST /api/screens
53+
Content-Type: application/json
54+
Authorization: <jwt-access-token>
55+
56+
{
57+
"screen": {
58+
"uri": "http://192.168.1.100:10000/lovelace/0?viewport=800x480&dithering=&dither_method=floyd-steinberg&palette=gray-4",
59+
"label": "Home Assistant",
60+
"name": "ha-dashboard",
61+
"model_id": "1",
62+
"preprocessed": true
63+
}
64+
}
65+
```
66+
67+
**Requirements:**
68+
69+
- `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.
70+
- The add-on's screenshot endpoint must be reachable without authentication, or the Add-on URL must include any credentials Terminus needs.
71+
- 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.
72+
73+
#### Setting the Add-on URL
74+
75+
> ⚠️ **This is the URL Terminus will use to reach the add-on — not the URL you use in your browser.**
76+
>
77+
> 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.
78+
79+
Think of it as two asymmetric network hops:
80+
81+
```
82+
You (browser) ──────▶ Add-on UI (your browser resolves "localhost")
83+
Add-on ──────▶ Terminus (webhook URL, see "Webhook URL" field)
84+
Terminus ──────▶ Add-on screenshot (Add-on URL, see this section)
85+
```
86+
87+
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.
88+
89+
Pick the value that matches your deployment topology:
90+
91+
| Where Terminus runs | Set Add-on URL to | Notes |
92+
|---|---|---|
93+
| 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 |
94+
| 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 |
95+
| A different machine on the same LAN | `http://<add-on-lan-ip>:10000` | Use the add-on host's routable IP, never `localhost` |
96+
| Behind a reverse proxy / public URL | `https://trmnl.example.com` | Whatever public hostname forwards to the add-on's port 10000 |
97+
| As a Home Assistant add-on, accessed via ingress | The add-on's ingress URL | See your HA installation's external ingress configuration |
98+
99+
**Verification shortcut.** Before retrying a failed schedule, shell into the Terminus container and confirm it can reach the add-on:
100+
101+
```sh
102+
docker compose -p terminus-development exec web \
103+
curl -sI http://host.docker.internal:10000/health
104+
# Expected: HTTP/1.1 200 OK
105+
```
106+
107+
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.
108+
109+
### Legacy Base64 Mode
110+
111+
Legacy mode embeds the image directly in the JSON body. Keep it selected only if your Terminus is `≤ 0.51.0`.
34112

35113
**Request:**
36114
```http
@@ -43,19 +121,32 @@ Authorization: <jwt-access-token>
43121
"data": "<base64-encoded-image>",
44122
"label": "Home Assistant",
45123
"name": "ha-dashboard",
46-
"model_id": "1"
124+
"model_id": "1",
125+
"file_name": "ha-dashboard.png",
126+
"preprocessed": true
47127
}
48128
}
49129
```
50130

131+
### Backward Compatibility
132+
133+
Schedules created before this feature shipped have no `delivery_mode` field in their stored config. The add-on treats them as follows:
134+
135+
- No `delivery_mode` + no Add-on URL → **Legacy base64** (preserves pre-existing behavior)
136+
- No `delivery_mode` + Add-on URL configured → **URI**
137+
- Explicit `delivery_mode: 'data'`**Legacy base64** (user choice wins even if Add-on URL is set)
138+
- Explicit `delivery_mode: 'uri'`**URI** (throws if Add-on URL is missing)
139+
51140
### Configuration Fields
52141

53142
| Field | Description |
54143
|-------|-------------|
55144
| `label` | Display name shown in BYOS UI |
56145
| `name` | Unique screen identifier (slug format) |
57146
| `model_id` | BYOS device model ID (from your BYOS setup) |
58-
| `preprocessed` | Whether the image is already optimized for e-ink |
147+
| `preprocessed` | Whether the image is already optimized for e-ink (always `true` from the add-on) |
148+
| `delivery_mode` | `'uri'` or `'data'`. Omitted on legacy schedules. |
149+
| `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). |
59150

60151
### JWT Authentication
61152

@@ -107,8 +198,11 @@ export class YourFormatTransformer implements FormatTransformer {
107198
imageBuffer: Buffer,
108199
format: ImageFormat,
109200
config?: YourFormatConfig,
201+
screenshotUrl?: string,
110202
): WebhookPayload {
111-
// Transform the image buffer into your payload format
203+
// Transform the image buffer into your payload format.
204+
// `screenshotUrl` is only set for URI-mode formats (see BYOS Hanami).
205+
// Formats that inline the image can ignore it.
112206
return {
113207
body: JSON.stringify({
114208
image: imageBuffer.toString('base64'),
@@ -182,15 +276,19 @@ Schedule Execution
182276
183277
ScheduleExecutor.call()
184278
185-
uploadToWebhook(options)
279+
#buildScreenshotUrl(schedule) ← URI mode only; undefined otherwise
186280
187-
getTransformer(webhookFormat) ← Strategy selection
281+
uploadToWebhook(options)
188282
189-
transformer.transform(buffer, format, config)
283+
getTransformer(webhookFormat) ← Strategy selection
190284
285+
transformer.transform(buffer, format, config, screenshotUrl)
286+
↓ ← BYOS transformer branches on delivery_mode
191287
fetch(webhookUrl, { body, headers })
192288
```
193289

290+
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.
291+
194292
The transformer is responsible for:
195293
- Converting the image buffer to the target payload format
196294
- Setting the appropriate `Content-Type` header

trmnl-ha/ha-trmnl/html/js/app.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ import type {
3333
WebhookFormat,
3434
WebhookFormatConfig,
3535
ByosAuthConfig,
36+
ByosDeliveryMode,
3637
} from '../../types/domain.js'
38+
import { BYOS_DEFAULT_DELIVERY_MODE } from '../../types/domain.js'
3739

3840
// =============================================================================
3941
// FORM PARSING HELPERS
@@ -531,6 +533,9 @@ class App {
531533
if (format === 'byos-hanami') {
532534
const name = input('s_byos_name') || 'ha-dashboard'
533535
const authEnabled = checkbox('s_byos_auth_enabled')
536+
const rawDeliveryMode = select('s_byos_delivery_mode')
537+
const deliveryMode: ByosDeliveryMode =
538+
rawDeliveryMode === 'uri' ? 'uri' : BYOS_DEFAULT_DELIVERY_MODE
534539

535540
// Preserve existing auth tokens (managed by byosLogin/byosLogout, not form)
536541
// If auth is enabled but no tokens yet, create empty auth object with enabled: true
@@ -544,6 +549,8 @@ class App {
544549
name,
545550
model_id: input('s_byos_model_id') || '1',
546551
preprocessed: true,
552+
delivery_mode: deliveryMode,
553+
addon_base_url: input('s_byos_addon_url') || undefined,
547554
auth: authEnabled ? (existingAuth ?? { enabled: true }) : undefined,
548555
},
549556
}
@@ -909,6 +916,19 @@ class App {
909916
await this.updateScheduleFromForm()
910917
}
911918

919+
/**
920+
* Toggles BYOS delivery mode (URI vs legacy base64) and shows/hides the
921+
* Add-on URL field which is only meaningful for URI mode.
922+
*/
923+
async toggleByosDeliveryMode(mode: string): Promise<void> {
924+
const urlField = document.getElementById('s_byos_addon_url_field')
925+
if (urlField) {
926+
urlField.classList.toggle('hidden', mode !== 'uri')
927+
}
928+
929+
await this.updateScheduleFromForm()
930+
}
931+
912932
/**
913933
* Authenticates with BYOS server. Credentials are NOT stored.
914934
*/

trmnl-ha/ha-trmnl/html/js/device-presets.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,16 @@ export class DevicePresetsManager {
140140
heightInput.dispatchEvent(new Event('change'))
141141
}
142142

143+
// Update crop dimensions to match device viewport
144+
const cropWidth = document.getElementById('s_crop_width') as HTMLInputElement | null
145+
const cropHeight = document.getElementById('s_crop_height') as HTMLInputElement | null
146+
if (cropWidth && device.viewport?.width) {
147+
cropWidth.value = device.viewport.width
148+
}
149+
if (cropHeight && device.viewport?.height) {
150+
cropHeight.value = device.viewport.height
151+
}
152+
143153
if (device.rotate) {
144154
const rotateSelect = document.getElementById(
145155
's_rotate',

trmnl-ha/ha-trmnl/html/js/ui-renderer.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
*/
1717

1818
import type { Schedule } from '../../types/domain.js'
19+
import { BYOS_DEFAULT_DELIVERY_MODE } from '../../types/domain.js'
1920
import type { PaletteOption } from './palette-options.js'
2021
import { buildScreenshotParams } from '../shared/build-screenshot-params.js'
2122
import { resolveScreenshotTarget } from '../shared/screenshot-target.js'
@@ -306,6 +307,26 @@ export class RenderScheduleContent {
306307
placeholder="1"
307308
title="BYOS model ID for your device" />
308309
</div>
310+
<div>
311+
<label class="block text-xs text-gray-600 mb-1">Delivery Mode</label>
312+
<select id="s_byos_delivery_mode"
313+
class="w-full px-2 py-1 text-sm border rounded-md" style="border-color: var(--primary-light)"
314+
onchange="window.app.toggleByosDeliveryMode(this.value)"
315+
title="How Terminus receives the screenshot">
316+
<option value="data" ${(byosConfig?.delivery_mode ?? BYOS_DEFAULT_DELIVERY_MODE) === 'data' ? 'selected' : ''}>Legacy base64 (Terminus ≤ 0.51.0)</option>
317+
<option value="uri" ${byosConfig?.delivery_mode === 'uri' ? 'selected' : ''}>URI (Terminus ≥ 0.52.0, recommended)</option>
318+
</select>
319+
<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>
320+
</div>
321+
<div id="s_byos_addon_url_field" class="${(byosConfig?.delivery_mode ?? BYOS_DEFAULT_DELIVERY_MODE) === 'uri' ? '' : 'hidden'}">
322+
<label class="block text-xs text-gray-600 mb-1">Add-on URL</label>
323+
<input type="text" id="s_byos_addon_url" value="${byosConfig?.addon_base_url || ''}"
324+
class="w-full px-2 py-1 text-sm border rounded-md" style="border-color: var(--primary-light)"
325+
onchange="window.app.updateScheduleFromForm()"
326+
placeholder="http://192.168.1.100:10000"
327+
title="External URL of this add-on (required for Terminus to fetch screenshots)" />
328+
<p class="text-xs text-gray-500 mt-1">Terminus fetches screenshots from this URL</p>
329+
</div>
309330
</div>
310331
311332
<!-- JWT Authentication -->

trmnl-ha/ha-trmnl/lib/scheduler/schedule-executor.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {
1212
uploadToWebhook,
1313
buildParams,
1414
} from './services.js'
15+
import { resolveScreenshotTarget } from '../../html/shared/screenshot-target.js'
16+
import { buildScreenshotParams } from '../../html/shared/build-screenshot-params.js'
1517
import {
1618
SCHEDULER_MAX_RETRIES,
1719
SCHEDULER_RETRY_DELAY_MS,
@@ -134,6 +136,27 @@ export class ScheduleExecutor {
134136
return outputPath
135137
}
136138

139+
/** Builds the screenshot endpoint URL for BYOS URI mode. Returns undefined when URI mode is not applicable. */
140+
#buildScreenshotUrl(schedule: Schedule): string | undefined {
141+
const byos = schedule.webhook_format?.byosConfig
142+
if (!byos?.addon_base_url) return undefined
143+
if (byos.delivery_mode === 'data') return undefined
144+
145+
const target = resolveScreenshotTarget(schedule)
146+
const params = buildScreenshotParams(schedule)
147+
148+
// NOTE: In generic mode the router serves the UI at `/` unless `url` is
149+
// present in the query. Thread target_url through so Terminus's fetch
150+
// hits the screenshot handler instead of the UI HTML.
151+
if (!target.isHAMode && target.fullUrl) {
152+
params.append('url', target.fullUrl)
153+
}
154+
155+
const path = target.path.replace(/^\//, '')
156+
157+
return `${byos.addon_base_url.replace(/\/+$/, '')}/${path}?${params.toString()}`
158+
}
159+
137160
/** Uploads to webhook if configured, returns result for UI feedback */
138161
async #uploadIfConfigured(
139162
schedule: Schedule,
@@ -143,6 +166,7 @@ export class ScheduleExecutor {
143166
if (!schedule.webhook_url) return undefined
144167

145168
const webhookUrl = schedule.webhook_url
169+
const screenshotUrl = this.#buildScreenshotUrl(schedule)
146170

147171
try {
148172
const result = await uploadToWebhook({
@@ -151,6 +175,7 @@ export class ScheduleExecutor {
151175
imageBuffer,
152176
format: format as 'png' | 'jpeg' | 'bmp',
153177
webhookFormat: schedule.webhook_format,
178+
screenshotUrl,
154179
onTokenRefresh: (newTokens) => {
155180
const byosConfig = schedule.webhook_format?.byosConfig
156181
if (!byosConfig?.auth) return

trmnl-ha/ha-trmnl/lib/scheduler/webhook-delivery.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@ export interface WebhookDeliveryOptions {
120120
format: ImageFormat
121121
/** Webhook payload format (null/undefined = 'raw' for backward compat) */
122122
webhookFormat?: WebhookFormatConfig | null
123+
/** Screenshot URL for BYOS URI mode (Terminus fetches from this URL) */
124+
screenshotUrl?: string
123125
/** Callback to persist refreshed BYOS JWT tokens */
124126
onTokenRefresh?: (newTokens: TokenResponse) => void
125127
}
@@ -147,6 +149,7 @@ export async function uploadToWebhook(
147149
imageBuffer,
148150
format,
149151
webhookFormat,
152+
screenshotUrl,
150153
onTokenRefresh,
151154
} = options
152155

@@ -160,6 +163,7 @@ export async function uploadToWebhook(
160163
imageBuffer,
161164
format,
162165
byosConfig,
166+
screenshotUrl,
163167
)
164168

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

0 commit comments

Comments
 (0)