TRMNL BYOS client
What this example shows
Use Inkplate 7SPECTRA as a client of a TRMNL-compatible "Bring Your Own Server" (BYOS) server. The sketch connects to Wi-Fi, registers with the server through /api/setup, then fetches /api/display, draws the image the server returns and goes to deep sleep until the next refresh. The server the sketch is written for is Terminus, TRMNL's official BYOS server, run with Docker.
The sketch's header describes this behavior:
- On the first start the screen shows a Connecting to WiFi... banner, then the device's MAC address. You need the MAC address to add the device on the server.
- The server's
api_keyis kept in RTC memory, which survives deep sleep, so the device registers only once. A full power loss clears it, and the server returns the same key for a known MAC address, so the device pairs again by itself. - On each later wake the sketch sends the key together with the battery voltage, the Wi-Fi signal strength (RSSI), the model, the panel resolution and what woke the device. It draws the returned image and sleeps for the refresh rate the server gives. If the server still serves the image already on the panel, the sketch skips the download and the refresh.
- Pressing the wake button (GPIO 18) during deep sleep wakes the device at once for a refresh.
- When Wi-Fi or the server fails, the sketch deep-sleeps and retries after an increasing delay. If Wi-Fi keeps failing, the device sleeps until the wake button is pressed.
Inkplate 7SPECTRA has no fast partial refresh, so the sketch prints the Wi-Fi connection dots to the Serial Monitor only. Images drawn with image.draw() are dithered to the six panel colors.
Before you upload
- Install the ArduinoJson library in the Arduino IDE's Library Manager.
- Set up the BYOS server. The sketch's header gives these steps for Terminus: install Docker, run TRMNL's quick start script for a first try (
curl https://raw.githubusercontent.com/usetrmnl/terminus/refs/heads/main/scripts/docker/quick.sh | bash, on Windows inside WSL2 or Git Bash) or clonehttps://github.com/usetrmnl/terminusand runbin/setupfor permanent use, then open port 2300 of the machine that runs Terminus in a browser and click Register to create your login. Run the quick start script only once. - Find your computer's LAN IP address so Inkplate can reach the server. It has to match the
API_URIvalue Terminus uses, which is in the.envfile made during setup. - In the Terminus dashboard, add the device under Devices with Add Device: pick the closest model or create one under Models, enter the Wi-Fi MAC address Inkplate shows on its screen after it connects, and set the refresh rate in seconds. Then build the content: create a template under Designs, check the rendered PNG under Screens, add it to a playlist under Playlists and assign the playlist to your device under Devices.
- In the sketch, write your network name and password between the quotes of the
ssidandpassdefines, and replaceYOUR_SERVER_IPinBYOS_SERVERwith the server's IP address, with no trailing slash.
The quick start command downloads a script from the main branch of the terminus repository, which can change at any time, and runs it straight away. Open the script's address in your browser and read it before you run the command.
Example code
#include "Inkplate.h"
#include <HTTPClient.h>
#include <WiFi.h>
#include <ArduinoJson.h>
#define ssid "" // Your WiFi SSID
#define pass "" // Your WiFi password
#define BYOS_SERVER "http://YOUR_SERVER_IP:2300"
// WAKE button (same pin as the Inkplate7SPECTRA_Wake_Up_Button example): pressing
// it during deep sleep wakes the device for an immediate refresh.
#define WAKE_BUTTON GPIO_NUM_18
// How long to wait for a WiFi connection before backing off (ms)
#define WIFI_TIMEOUT_MS 30000
// Escalating backoff schedules (seconds). Once WIFI_RETRY_TIMES is exhausted
// the device sleeps until the WAKE button is pressed so a dead AP can't
// drain the battery; once API_RETRY_TIMES is exhausted it settles at
// API_FALLBACK_SLEEP.
const int WIFI_RETRY_TIMES[] = {60, 180, 300};
const int API_RETRY_TIMES[] = {15, 30, 60};
#define API_FALLBACK_SLEEP 900
#define NOT_READY_SLEEP 60 // short poll while the server reports not-ready (202/500)
Inkplate display;
String deviceId = "";
// Access-Token issued by /api/setup; preserved across deep sleep so the
// device only registers once. Lost only on full power loss, which is fine -
// the server returns the same key for a known MAC address.
RTC_DATA_ATTR char apiKey[64] = "";
// Last server-issued refresh cadence (seconds), reported back on the next
// request via the Refresh-Rate header
RTC_DATA_ATTR int lastRefreshRate = 900;
// Escalating backoff positions, preserved across deep sleep
RTC_DATA_ATTR int wifiRetryCount = 0;
RTC_DATA_ATTR int apiRetryCount = 0;
// Name of the image currently on the panel: e-paper keeps showing it through
// deep sleep, so an unchanged image needs no download or refresh at all
RTC_DATA_ATTR char lastImageName[256] = "";
double batteryVoltage = 0.0; // read before WiFi so radio current doesn't skew it
// Reported to the server via the Update-Source header
const char *wakeupSource()
{
switch (esp_sleep_get_wakeup_cause())
{
case ESP_SLEEP_WAKEUP_TIMER:
return "timer";
case ESP_SLEEP_WAKEUP_EXT0:
return "button";
default:
return "powercycle";
}
}
void setup()
{
Serial.begin(115200);
display.begin();
// Read the battery BEFORE the radio is energized so WiFi current doesn't
// depress the voltage reported to the server.
batteryVoltage = display.readBattery();
// Painting the panel here would wipe the current image between routine
// refreshes, so only show the connect/pairing banner while the device
// isn't registered yet (it displays the MAC address needed to add the
// device on the server).
bool showBanner = (strlen(apiKey) == 0);
if (showBanner)
{
display.clearDisplay();
display.setTextSize(1);
display.setCursor(0, 0);
display.setTextColor(INKPLATE_BLACK, INKPLATE_WHITE);
display.println("Connecting to WiFi...");
display.display();
}
WiFi.mode(WIFI_STA);
WiFi.begin(ssid, pass);
unsigned long start = millis();
while (WiFi.status() != WL_CONNECTED)
{
if (millis() - start > WIFI_TIMEOUT_MS)
{
if (showBanner)
{
display.println("WiFi failed; will retry with backoff");
// This panel has no partial refresh, so a one-off full refresh
// is acceptable here before the long backoff sleep.
display.display();
}
wifiErrorSleep(); // deep-sleeps and never returns
}
delay(1000);
Serial.print('.'); // No fast partial refresh on this panel, so progress goes to Serial only
}
wifiRetryCount = 0; // connected: restart the escalation from the top
deviceId = WiFi.macAddress(); // TRMNL identifies devices by MAC address
if (showBanner)
{
display.clearDisplay();
display.setCursor(0, 0);
display.print("Connected. ID: ");
display.print(deviceId);
display.display();
}
if (strlen(apiKey) == 0)
doSetup(); // Register with the BYOS server (first boot only)
doDisplay(); // Fetch and show the first screen
}
void loop()
{
// Everything happens in setup() + deep sleep below;
// ESP32 wakes up, runs setup() again, fetches next screen.
}
void doSetup()
{
HTTPClient http;
String url = String(BYOS_SERVER) + "/api/setup";
if (http.begin(url))
{
http.addHeader("ID", deviceId);
int code = http.GET();
if (code == 200)
{
String payload = http.getString();
Serial.println("Setup response: " + payload);
JsonDocument doc;
if (deserializeJson(doc, payload) == DeserializationError::Ok)
{
const char *key = doc["api_key"] | "";
if (strlen(key) > 0)
{
strncpy(apiKey, key, sizeof(apiKey) - 1);
apiKey[sizeof(apiKey) - 1] = '\0';
Serial.println("Registered with server; api_key stored");
}
}
}
else
{
Serial.printf("Setup request failed, HTTP %d\n", code);
}
http.end();
}
}
// image.draw() picks its decoder from the URL's file extension, so a query
// string or extension-less URL would fail without even downloading; reject
// those up front with a clear log message.
bool hasDrawableExtension(const char *url)
{
String u(url);
String ext = u.substring(u.lastIndexOf('.') + 1);
ext.toLowerCase();
return ext == "bmp" || ext == "dib" || ext == "png" || ext == "jpg" || ext == "jpeg";
}
void doDisplay()
{
HTTPClient http;
String url = String(BYOS_SERVER) + "/api/display";
if (!http.begin(url))
{
Serial.println("http.begin() failed");
apiErrorSleep(); // deep-sleeps and never returns
}
http.addHeader("ID", deviceId);
if (strlen(apiKey) > 0)
http.addHeader("Access-Token", apiKey);
http.addHeader("Content-Type", "application/json");
http.addHeader("Update-Source", wakeupSource());
http.addHeader("Refresh-Rate", String(lastRefreshRate));
http.addHeader("Battery-Voltage", String(batteryVoltage, 2));
http.addHeader("RSSI", String(WiFi.RSSI()));
http.addHeader("Model", "inkplate_7spectra");
http.addHeader("Width", String(display.width()));
http.addHeader("Height", String(display.height()));
http.addHeader("Image-Cached", strlen(lastImageName) > 0 ? "true" : "false");
int code = http.GET();
if (code <= 0 || code >= 400)
{
Serial.printf("Display request failed, HTTP %d\n", code);
http.end();
apiErrorSleep();
}
String payload = http.getString();
http.end();
Serial.println("Display response: " + payload);
JsonDocument doc;
if (deserializeJson(doc, payload) != DeserializationError::Ok)
{
Serial.println("JSON parse failed");
apiErrorSleep();
}
long refreshRate = doc["refresh_rate"].as<long>(); // integer, not string
if (refreshRate <= 0)
refreshRate = 900;
lastRefreshRate = (int)refreshRate;
// The device API reports state in the "status" body field: 0 = image
// ready, 202 = registered but no screen assigned yet, 500 = server error.
int status = doc["status"].as<int>();
if (status != 0)
{
apiRetryCount = 0; // the server answered, so the API path is healthy
if (status == 202 || status == 500)
{
Serial.printf("Server not ready (status %d); polling again shortly\n", status);
goToSleep(NOT_READY_SLEEP);
}
Serial.printf("Unknown status %d; keeping current image\n", status);
goToSleep(refreshRate);
}
apiRetryCount = 0;
String imageUrl = doc["image_url"].as<String>();
bool updateFirmware = doc["update_firmware"].as<bool>();
if (imageUrl.length() == 0)
{
Serial.println("No image_url in response");
apiErrorSleep();
}
// Prefer the stable filename as the identity of the image, falling back
// to the URL
const char *filename = doc["filename"] | "";
String imageName = strlen(filename) > 0 ? String(filename) : imageUrl;
if (imageName == lastImageName)
{
// The e-paper still shows this exact image; skip the download and
// the costly full refresh entirely.
Serial.println("Image unchanged; skipping redraw");
goToSleep(refreshRate);
}
if (!hasDrawableExtension(imageUrl.c_str()))
{
Serial.println("image_url has no drawable extension: " + imageUrl);
apiErrorSleep();
}
display.clearDisplay();
bool ok = display.image.draw(imageUrl.c_str(), 0, 0, true, false);
if (!ok)
{
display.setCursor(0, 0);
display.print("Failed to draw image from URL");
display.display();
apiErrorSleep(); // lastImageName is untouched, so next wake retries
}
display.display();
strncpy(lastImageName, imageName.c_str(), sizeof(lastImageName) - 1);
lastImageName[sizeof(lastImageName) - 1] = '\0';
if (updateFirmware)
{
Serial.println("Firmware update flagged - not implemented, skipping.");
}
goToSleep(refreshRate);
}
void goToSleep(long seconds)
{
WiFi.disconnect(true);
WiFi.mode(WIFI_OFF);
esp_sleep_enable_timer_wakeup((uint64_t)seconds * 1000000ULL);
esp_sleep_enable_ext0_wakeup(WAKE_BUTTON, LOW);
esp_deep_sleep_start();
}
// Sleep with no timer so only a WAKE press revives the device
void goToSleepButtonOnly()
{
WiFi.disconnect(true);
WiFi.mode(WIFI_OFF);
esp_sleep_enable_ext0_wakeup(WAKE_BUTTON, LOW);
esp_deep_sleep_start();
}
// Escalating sleep after an API/HTTP/parse failure
void apiErrorSleep()
{
int seconds;
if (apiRetryCount < (int)(sizeof(API_RETRY_TIMES) / sizeof(API_RETRY_TIMES[0])))
seconds = API_RETRY_TIMES[apiRetryCount++];
else
seconds = API_FALLBACK_SLEEP;
goToSleep(seconds);
}
// Escalating sleep after a WiFi connect failure
void wifiErrorSleep()
{
if (wifiRetryCount >= (int)(sizeof(WIFI_RETRY_TIMES) / sizeof(WIFI_RETRY_TIMES[0])))
goToSleepButtonOnly();
goToSleep(WIFI_RETRY_TIMES[wifiRetryCount++]);
}
This example also uses WiFi, HTTPClient and the ESP32 sleep functions (arduino-esp32), which come with the ESP32 board package and are not part of the Inkplate library. It also uses ArduinoJson (arduinojson.org), which is not part of the Inkplate library either. Their functions below are described as the sketch uses them.
Functions used
Inkplate()returns NoneCreates the Inkplate object. Inkplate 7SPECTRA has a single six-color mode, so the constructor takes no mode argument.
ReturnsConstructor; no return value.
display.begin()returns voidInitializes the library, the peripherals and the frame buffer. Call it once in setup().
ReturnsNothing.
display.readBattery()returns doubleMeasures the battery voltage. The sketch reads it before Wi-Fi starts, so the radio's current does not lower the value it reports to the server.
ReturnsBattery voltage in volts.
display.clearDisplay()returns voidFills the frame buffer with white. It does not change the panel.
ReturnsNothing.
display.setTextSize(uint8_t s)returns voidSets the text magnification, here 1.
ReturnsNothing.
display.setCursor(int16_t x, int16_t y)returns voidSets where the next printed text starts, here the top left corner.
ReturnsNothing.
display.setTextColor(uint16_t c, uint16_t bg)returns voidSets the text color and the background color drawn behind each character, here black on white.
ReturnsNothing.
display.println(value)returns size_tPrints text into the frame buffer and moves to the next line, like Serial.println().
ReturnsNumber of bytes printed.
display.print(value)returns size_tPrints text into the frame buffer, like Serial.print().
ReturnsNumber of bytes printed.
display.display(bool _leaveOn = 0)returns voidSends the whole frame buffer to the panel and refreshes it.
ReturnsNothing.
display.width()returns int16_tReturns the width of the panel in pixels. The sketch sends it to the server in the Width header.
ReturnsPanel width in pixels.
display.height()returns int16_tReturns the height of the panel in pixels. The sketch sends it to the server in the Height header.
ReturnsPanel height in pixels.
display.image.draw(const char *path, int x, int y, bool dither = 1, bool invert = 0)returns boolDraws an image at x, y. The sketch gives it the image URL from the server's reply, dither on and invert off. The library picks the decoder from the file extension of the address, so the sketch accepts only bmp, dib, png, jpg and jpeg and rejects an address with a query string or no extension.
Returnstrue if the image was drawn.
WiFi.mode(wifi_mode_t mode)returns boolSets the Wi-Fi mode: WIFI_STA to connect to a network, WIFI_OFF before sleep.
Returnstrue if the mode was set.
WiFi.begin(const char *ssid, const char *pass)returns wl_status_tStarts connecting to the Wi-Fi network.
ReturnsThe connection status.
WiFi.status()returns wl_status_tReturns the connection status. The sketch waits until it is WL_CONNECTED, for at most 30 seconds.
ReturnsThe connection status.
WiFi.macAddress()returns StringReturns the Wi-Fi MAC address as text. TRMNL identifies the device by it.
ReturnsThe MAC address.
WiFi.RSSI()returns int8_tReturns the Wi-Fi signal strength, which the sketch sends in the RSSI header.
ReturnsSignal strength in dBm.
WiFi.disconnect(bool wifioff)returns boolDisconnects from the network. The sketch passes true, which also turns the Wi-Fi radio off, before deep sleep.
Returnstrue if it disconnected.
HTTPClientreturns NoneHTTP client class. The sketch creates an object, http, in each of the two request functions.
ReturnsType; no return value.
HTTPClient.begin(String url)returns boolStarts an HTTP request to the address.
Returnstrue if the request could be started.
HTTPClient.addHeader(const String &name, const String &value)returns voidAdds a header to the request. The sketch sends ID, Access-Token, Content-Type, Update-Source, Refresh-Rate, Battery-Voltage, RSSI, Model, Width, Height and Image-Cached.
ReturnsNothing.
HTTPClient.GET()returns intSends the GET request.
ReturnsThe HTTP status code, or a negative number on a connection error.
HTTPClient.getString()returns StringReturns the body of the reply as text.
ReturnsThe reply body.
HTTPClient.end()returns voidEnds the request and frees its resources.
ReturnsNothing.
deserializeJson(JsonDocument &doc, const String &input)returns DeserializationErrorArduinoJson function that parses JSON text into a document. The sketch compares the result with DeserializationError::Ok.
ReturnsDeserializationError::Ok when the text was parsed.
JsonDocumentreturns NoneArduinoJson document that holds the parsed reply. The sketch reads api_key, refresh_rate, status, image_url, update_firmware and filename from it.
ReturnsType; no return value.
esp_sleep_get_wakeup_cause()returns esp_sleep_wakeup_cause_tReturns what woke the ESP32. The sketch reports ESP_SLEEP_WAKEUP_TIMER as timer, ESP_SLEEP_WAKEUP_EXT0 as button and anything else as powercycle in the Update-Source header.
ReturnsThe wake-up cause.
esp_sleep_enable_timer_wakeup(uint64_t time_in_us)returns esp_err_tSets the deep sleep timer, in microseconds. The sketch converts the server's refresh rate from seconds.
ReturnsESP_OK on success.
esp_sleep_enable_ext0_wakeup(gpio_num_t gpio_num, int level)returns esp_err_tWakes the ESP32 from deep sleep when the pin reaches the level. The sketch uses GPIO_NUM_18, the wake button, at LOW.
ReturnsESP_OK on success.
esp_deep_sleep_start()returns voidStarts deep sleep. It does not return; the board runs setup() again on wake-up.
ReturnsNothing.
Serial.begin(unsigned long baud)returns voidStarts serial communication at the given baud rate, here 115200.
ReturnsNothing.
Serial.println(value)returns size_tPrints text followed by a new line to the Serial Monitor.
ReturnsNumber of bytes printed.
What to expect
On the first start the screen shows Connecting to WiFi..., then Connected. ID: followed by the board's MAC address. Once the device is registered and the server has a screen for it, the screen shows that image and the board sleeps until the next refresh. In the Serial Monitor the sketch prints progress dots while it connects and a line for each step, for example Setup response:, Registered with server; api_key stored and Display response: followed by the server's reply.
🔗Open the complete example
examples/Inkplate7SPECTRA/Advanced/TRMNL/Inkplate7SPECTRA_TRMNL_BYOS