* feat: expose app config and render fields via the installations API Follows #783, which brought the schedule fields across from the web UI. The same gap remained for everything else the app config page writes: config itself, autoPin, colorFilter and showFullAnimation were reachable only through a session, so an API-driven setup could install and schedule an app but never configure it. PATCH /v0/devices/{id}/installations/{iname} gains all four. autoPin, colorFilter and showFullAnimation are added to the GET payload too, so a client can diff current state before writing. config is write-only. It holds whatever the app's schema defines, which for many apps means API keys and OAuth tokens, and a device API key is a lower bar than a logged-in session — so it can be set but is never read back. It replaces the whole map, matching handleConfigAppPost; there is no per-key merge. Two behavior changes worth calling out: - PATCH previously encoded data.App directly, which carried the app's entire config — tokens included — into the response body, while GET deliberately omits it. It now answers with the same AppPayload shape GET uses. This changes the response from snake_case to camelCase for anyone reading it; TestHandlePatchInstallationSchedule was asserting on the old shape and is updated. - An unknown colorFilter is now a 400 rather than being stored. The validity list is the one the config page already offers. API.md: document the new fields, and correct the installations example, which still showed iname/display_time/u_interval/last_render and a config key that AppPayload has never returned. * fix: validate the whole installation update before applying any of it handlePatchInstallation applied fields as it walked them, so a request carrying one good field and one bad one left the good half in place: a `{"pinned": true, "colorFilter": "chartreuse"}` PATCH saved the device pin and then answered 400, and disabling an app deleted its rendered webp files before a later field could reject the request. Move the two blocks with effects outside the in-memory app -- the enabled block's file deletion and the pinned block's device save -- to the end, after every validating field. The app itself is loaded per request, so the staged in-memory changes are discarded when a validation returns early and only the final Save persists anything. Also address review feedback on the new test: use testify per AGENTS.md, and rename the `clear` local, which the predeclared linter rejects. * fix: commit the installation update before deleting its renders Disabling an app deleted its webp files and then saved the row, so a failed save left the app enabled in the database with its renders gone. The pin was a separate write for the same reason: it landed even when the app save that followed it failed. Write the app row and the device's pinned_app in one transaction, using the same column-scoped update handleDeleteApp already uses, and move the render cleanup after the commit. Cleanup is now post-commit reconciliation: it can only leave stale files behind, so it logs instead of failing a request whose state change already succeeded. A pin write that fails now reports "Failed to update app" rather than "Failed to update device pin status" -- there is one operation to fail. * fix: treat an installation name as untrusted when cleaning up renders Every iname the UI and the API create is a server-generated number, but handleImportDeviceConfig creates apps straight from an uploaded config and stores whatever iname it carries. That value then reaches the filesystem twice during disable cleanup: - filepath.Glob(webpDir + "*-" + iname + ".webp"): an iname of "*" matches, and deletes, every render on the device. - filepath.Join(webpDir, "pushed", iname + ".webp"): an iname of "../../victim" resolves out of the device directory entirely, into the sibling directory holding another device's renders. Reject an iname that is not a plain path component, and match renders by name rather than by glob, so metacharacters cannot widen the pattern. TestRemoveAppRendersRejectsUnsafeIname covers both; each half fails against the previous implementation. This only hardens the disable path. The root cause is that the import handler stores an unvalidated iname, and the other code that builds paths from one is untouched here.
8.8 KiB
Tronbyt Server API v0 Reference
Base URL: http://<server>:8000
Authentication
All API endpoints require authentication via Bearer token:
Authorization: Bearer <api-key>
API keys are generated per-device in the web UI. Each key is scoped to a single device.
Devices
List Devices
GET /v0/devices
Returns all devices accessible to the authenticated API key.
Response:
{
"devices": [
{
"id": "gen1",
"type": "gen1",
"displayName": "My Tronbyt",
"notes": "",
"intervalSec": 30,
"brightness": 100,
"nightMode": {
"enabled": false,
"app": "",
"startTime": "",
"endTime": "",
"brightness": 0
},
"dimMode": {
"startTime": null,
"brightness": null
},
"pinnedApp": null,
"interstitial": {
"enabled": false,
"app": null
},
"lastSeen": "2024-01-01T00:00:00Z",
"info": {
"firmwareVersion": "1.0.0",
"firmwareType": "gen1",
"protocolVersion": 1,
"macAddress": "aa:bb:cc:dd:ee:ff"
},
"autoDim": false
}
]
}
Get Device
GET /v0/devices/{id}
Returns details for a specific device.
Update Device
PATCH /v0/devices/{id}
Content-Type: application/json
Update device settings. All fields are optional.
Request:
{
"brightness": 80,
"intervalSec": 60,
"nightModeEnabled": true,
"nightModeApp": "clock",
"nightModeBrightness": 10,
"nightModeStartTime": "22:00",
"nightModeEndTime": "07:00",
"dimModeStartTime": "20:00",
"dimModeBrightness": 30,
"pinnedApp": "weather"
}
Response: Updated device payload (same shape as GET).
Reboot Device
POST /v0/devices/{id}/reboot
Sends a reboot command to the device via WebSocket. Returns immediately; the device reboots asynchronously.
Response: 200 OK — "Reboot command sent."
Update Firmware Settings
POST /v0/devices/{id}/update_firmware_settings
Content-Type: application/json
Update low-level firmware settings. All fields are optional.
Request:
{
"skipDisplayVersion": true,
"skipBootAnimation": true,
"preferIPv6": false,
"apMode": false,
"swapColors": false,
"wifiPowerSave": 0,
"imageUrl": "http://example.com/image.webp",
"hostname": "tronbyt.local",
"sntpServer": "pool.ntp.org",
"syslogAddr": "192.168.1.100:514"
}
Response: 200 OK — "Firmware settings updated."
Installations (Apps)
List Installations
GET /v0/devices/{id}/installations
Returns all app installations on a device.
Response:
{
"installations": [
{
"id": "my-clock",
"appID": "clock",
"enabled": true,
"pinned": false,
"pushed": false,
"renderIntervalMin": 0,
"displayTimeSec": 30,
"lastRenderAt": 1704067200,
"isInactive": false,
"startTime": "09:00",
"endTime": "17:00",
"days": ["monday", "wednesday", "friday"],
"useCustomRecurrence": false,
"recurrenceType": "",
"recurrenceInterval": 0,
"recurrencePattern": null,
"recurrenceStartDate": null,
"recurrenceEndDate": null,
"autoPin": false,
"colorFilter": null,
"showFullAnimation": null
}
]
}
App config is not returned. It holds whatever the app's schema defines,
which for many apps includes API keys and OAuth tokens, and a device API key is
a lower bar than a logged-in session. Config can be written (see below) but
never read back.
Get Installation
GET /v0/devices/{id}/installations/{iname}
Returns details for a specific app installation.
Update Installation
PATCH /v0/devices/{id}/installations/{iname}
Content-Type: application/json
Update installation settings. All fields are optional; omitting one leaves it alone.
Request:
{
"enabled": true,
"pinned": false,
"renderIntervalMin": 5,
"displayTimeSec": 30,
"startTime": "09:00",
"endTime": "17:00",
"days": ["monday", "wednesday", "friday"],
"autoPin": false,
"colorFilter": "dimmed",
"showFullAnimation": "true",
"config": { "timezone": "America/New_York" }
}
| Field | Notes |
|---|---|
startTime / endTime |
"HH:MM". "" clears. A start later than the end wraps overnight. |
days |
Lowercase day names. [] means every day. |
colorFilter |
One of the filters the app config page offers. "" or "inherit" falls back to the device setting. |
showFullAnimation |
"true" / "false", or "auto" to inherit. Lets an animation run past the app's display time. |
config |
Replaces the app's whole config map — there is no per-key merge, so send the full object. Write-only: it is never returned by GET or in this response. |
Response: Updated installation object, in the same shape GET returns.
Delete Installation
DELETE /v0/devices/{id}/installations/{iname}
Removes an app installation and its associated WebP files.
Response: 200 OK — "App deleted."
Push (Render & Display)
Push App
POST /v0/devices/{id}/push_app
Content-Type: application/json
Renders an app and pushes it to the device. If background is false, the device immediately interrupts its current display and shows the pushed image.
Request:
{
"app_id": "clock",
"installationID": "my-clock",
"config": {
"timezone": "America/New_York"
},
"background": false
}
| Field | Required | Description |
|---|---|---|
app_id |
No* | The app identifier (e.g. "clock", "weather"). Required only if installationID is not provided or references a non-existent installation. |
installationID |
No | Installation name. If provided and valid, the app path is inferred from the existing installation, and its saved config is used if config is omitted. |
config |
No | App configuration. If omitted and installationID is provided, uses saved config from that installation. |
background |
No | If true, saves the image without interrupting the device (default: false) |
Response: 200 OK — "App pushed."
Examples:
Push with explicit app_id (required when not using installationID):
curl -X POST \
-H "Authorization: Bearer <key>" \
-H "Content-Type: application/json" \
-d '{"app_id": "clock", "config": {"timezone": "America/New_York"}, "background": false}' \
http://localhost:8000/v0/devices/gen1/push_app
Activate an existing installation (app_id inferred from installation):
curl -X POST \
-H "Authorization: Bearer <key>" \
-H "Content-Type: application/json" \
-d '{"installationID": "851", "background": false}' \
http://localhost:8000/v0/devices/gen1/push_app
Push Raw Image
POST /v0/devices/{id}/push
Content-Type: application/json
Pushes a base64-encoded WebP image directly to the device.
Request:
{
"installationID": "my-image",
"image": "<base64-encoded-webp>",
"background": false
}
| Field | Required | Description |
|---|---|---|
installationID |
No | Identifier for the pushed image |
image |
Yes | Base64-encoded WebP image bytes |
background |
No | If true, saves without interrupting (default: false) |
Response: 200 OK — "WebP received."
Example:
curl -X POST \
-H "Authorization: Bearer <key>" \
-H "Content-Type: application/json" \
-d '{
"installationID": "custom",
"image": "'$(base64 -w0 image.webp)'",
"background": false
}' \
http://localhost:8000/v0/devices/gen1/push
Common Patterns
Activate an existing app immediately
- Push using the installationID — app path and config are inferred from the installation:
curl -X POST \
-H "Authorization: Bearer <key>" \
-H "Content-Type: application/json" \
-d '{"installationID": "851", "background": false}' \
http://localhost:8000/v0/devices/gen1/push_app
Enable/disable an app
curl -X PATCH \
-H "Authorization: Bearer <key>" \
-H "Content-Type: application/json" \
-d '{"enabled": false}' \
http://localhost:8000/v0/devices/gen1/installations/851
Pin an app (always show it)
curl -X PATCH \
-H "Authorization: Bearer <key>" \
-H "Content-Type: application/json" \
-d '{"pinned": true}' \
http://localhost:8000/v0/devices/gen1/installations/851
Set device brightness
curl -X PATCH \
-H "Authorization: Bearer <key>" \
-H "Content-Type: application/json" \
-d '{"brightness": 50}' \
http://localhost:8000/v0/devices/gen1
Error Responses
| Status | Meaning |
|---|---|
400 |
Invalid JSON, missing fields, or bad values |
401 |
Missing or invalid API key |
404 |
Device or installation not found |
500 |
Internal server error |
Error responses are plain text with a brief message.