Build apps
Settings
Declare what people set when they add your app, and read it when you draw.
On this page
settings returns an object with one entry per setting. The key is the setting's name in code: it
names the value in params, and letters, digits and underscores make it up, not starting with a
digit. The value is a field made by one of the builders in @ledable/sdk/app, which takes the
setting's name and description as people see them, and the rules of its type.
This example is a sign for an office door. It has five settings: a status, a message that depends on the status, a time that only shows while you are away, a colour and a switch.
import {
boolean, colorRgb, defineApp, select, time, type TimeValue,
} from "@ledable/sdk/app";
import { Frame, Rect, Text, renderFrame } from "@ledable/sdk/graphics";
import type { BitmapFont } from "@ledable/sdk/graphics";
import { MODERNDOS, TINY, loadFonts } from "@ledable/sdk/fonts";
const FONTS = { status: MODERNDOS, small: TINY } as const;
type Fonts = { readonly [Key in keyof typeof FONTS]: BitmapFont };
/** What the sign says and how it looks, once the settings are read. */
interface Sign {
readonly status: string;
readonly message: string | null;
readonly backAt: TimeValue | null;
readonly color: string;
readonly border: boolean;
}
function hex(rgb: readonly [number, number, number]): string {
const parts = rgb.map(part => part.toString(16).padStart(2, "0"));
return `#${parts.join("")}`;
}
function clock({ hour, minute }: TimeValue): string {
return `${hour}:${String(minute).padStart(2, "0")}`;
}
function draw(fonts: Fonts, sign: Sign) {
// With no time set the last line stays empty, rather than showing a
// time nobody chose.
const footer =
sign.backAt === null ? null : `back ${clock(sign.backAt)}`;
return renderFrame(
<Frame width={64} height={32} background="#000000">
{sign.border ? (
<Rect x={0} y={0} width={64} height={32} stroke={sign.color} />
) : null}
<Text x={32} y={9} anchor="mm" text={sign.status.toUpperCase()}
font={fonts.status} color={sign.color} />
{sign.message === null ? null : (
<Text x={32} y={19} anchor="mm" text={sign.message}
font={fonts.small} color="#ffffff" />
)}
{footer === null ? null : (
<Text x={32} y={26} anchor="mm" text={footer}
font={fonts.small} color="#9a9a9a" />
)}
</Frame>,
);
}
export default defineApp({
settings: () => ({
status: select({
name: "Status",
description: "Whether people may come in",
options: ["Open", "Busy", "Away"],
default: "Open",
}),
// Each status has messages of its own. No default: a default that
// the chosen status does not offer would count as unset anyway.
message: select({
name: "Message",
description: "A line under the status",
depends_on: "status",
options_by: {
Open: ["Come in", "Knock first"],
Busy: ["In a call", "Focusing", "Recording"],
Away: ["At lunch", "In a meeting", "Gone home"],
},
}),
back_at: time({
name: "Back at",
description: "When you will be back",
visible_when: { field: "status", in: ["Away"] },
}),
color: colorRgb({
name: "Colour",
description: "The colour of the status and the border",
default: [246, 205, 109],
}),
border: boolean({
name: "Border",
description: "Draw a line around the edge",
default: true,
}),
}),
async render(ctx, params) {
const fonts = await loadFonts(ctx.assets, FONTS);
const sign = {
status: params.status,
message: params.message,
backAt: params.back_at,
color: hex(params.color),
border: params.border,
};
return { image: [draw(fonts, sign)] };
},
async preview(ctx) {
const fonts = await loadFonts(ctx.assets, FONTS);
const sign = {
status: "Away",
message: "At lunch",
backAt: { hour: 13, minute: 30, second: 0 },
color: "#f6cd6d",
border: true,
};
return { image: [draw(fonts, sign)] };
},
});
Field builders
| Builder | What people set | Value in params | Rules |
|---|---|---|---|
string | Text | string | min_length, max_length |
integer | A whole number | number | min, max |
float | A number | number | min, max |
boolean | A switch | boolean | |
select | One of a list | string | options, or depends_on and options_by |
multiselect | Any of a list | readonly string[] | options |
date | A calendar day | { year, month, day } | |
time | A time of day | { hour, minute, second } | |
datetime | A moment | Date | |
timezone | A time zone | string, such as Europe/Berlin | |
country | A country | string, its two-letter code such as DE | |
location | A place | LocationValue: lat, lng, desc and more | |
colorRgb | A colour | [red, green, blue], each 0 to 255 | |
colorRgba | A colour with opacity | [red, green, blue, alpha] |
Two more builders, account and credential, let people connect their own accounts and tokens;
see Accounts and credentials. Keys of your own are not settings but
secrets.
Each of the builders in the table also takes required and default. A default is written in the
type's own terms, except three:
datetakes"YYYY-MM-DD"andtimetakes"HH:MM:SS", as strings;datetimetakes a number of seconds since 1 January 1970, UTC.
A location default is an object with at least lat, lng and desc, the place's name.
This app uses most of the other builders:
import {
colorRgba, country, date, datetime, defineApp, float, integer,
location, multiselect, string, timezone,
} from "@ledable/sdk/app";
export default defineApp({
settings: () => ({
title: string({
name: "Title",
description: "Shown above the numbers",
min_length: 1,
max_length: 12,
required: true,
}),
rows: integer({
name: "Rows",
description: "How many rows to show",
min: 1,
max: 4,
default: 2,
}),
threshold: float({
name: "Threshold",
description: "Highlight values above this",
min: 0,
}),
days: multiselect({
name: "Days",
description: "The days it shows on",
options: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
default: ["Mon", "Tue", "Wed", "Thu", "Fri"],
}),
start: date({
name: "Start",
description: "The first day to count",
default: "2026-01-01",
}),
deadline: datetime({
name: "Deadline",
description: "When it is due",
}),
zone: timezone({
name: "Time zone",
description: "Where the deadline is",
default: "Europe/Berlin",
}),
home: country({
name: "Country",
description: "For its public holidays",
default: "DE",
}),
place: location({
name: "Place",
description: "Where to look",
required: true,
}),
tint: colorRgba({
name: "Tint",
description: "Laid over the picture",
default: [0, 0, 0, 128],
}),
}),
// render and preview as in any app
});Required, default and null
What render receives for a setting nobody set follows from the field, and so does its type:
| The field has | When unset, params holds | Type |
|---|---|---|
required: true | Never unset: the app cannot be added until it is set | the value |
default | The default | the value |
| Neither | null | the value or null |
In the grouped example above, params.title is a string, params.rows a number and
params.threshold a number | null. In the door sign, params.back_at is a TimeValue | null,
and the render leaves the last line empty when it is null.
The types come from what settings returns, so render needs no annotations, and TypeScript
reports a null you did not handle.
Showing a setting only sometimes
visible_when shows a setting only while another one holds one of the values in in. In the door
sign, Back at shows only while the status is Away:
back_at: time({
name: "Back at",
description: "When you will be back",
visible_when: { field: "status", in: ["Away"] },
}),The setting it names must be a select with fixed options, or a boolean, written "true" and
"false" in in; it must have no visible_when or depends_on of its own. A hidden setting is
not sent, whatever it held before: render gets its default, or null.
Options that follow another setting
A select with depends_on and options_by offers the list options_by holds for the value of
another setting. In the door sign, the messages follow the status:
// Each status has messages of its own. No default: a default that
// the chosen status does not offer would count as unset anyway.
message: select({
name: "Message",
description: "A line under the status",
depends_on: "status",
options_by: {
Open: ["Come in", "Knock first"],
Busy: ["In a call", "Focusing", "Recording"],
Away: ["At lunch", "In a meeting", "Gone home"],
},
}),Such a select has no options of its own, and it follows a select with fixed options, whose
values are the keys of options_by. When people change the status, a message the new status does
not offer reads as unset. For the same reason a default rarely fits a select that follows another:
a default the current status does not offer is no default, and render gets null.
Options from data
settings can be async and read data, so a list of options can come from another service. Its
ctx has now, cache, secrets and assets, and nothing of any person or display, because
everybody gets the same form. fetch reaches the hosts the app declares, as in a render; see
Data and the network.
import * as z from "zod/mini";
import { defineApp, select } from "@ledable/sdk/app";
const HOUR_MS = 60 * 60 * 1000;
const currenciesSchema = z.object({ codes: z.array(z.string()) });
export default defineApp({
async settings(ctx) {
// One list for everybody, fetched at most once a day.
const codes = await ctx.cache.get("currencies", async () => {
const response = await fetch("https://api.example.com/currencies");
if (!response.ok) throw new Error(`Currencies: ${response.status}`);
const body = z.parse(currenciesSchema, await response.json());
return { value: body.codes, ttlMs: 24 * HOUR_MS };
});
return {
currency: select({
name: "Currency",
description: "The currency prices are shown in",
options: codes,
default: "EUR",
}),
};
},
settingsTtlMs: 6 * HOUR_MS,
// render and preview as in any app
});The cache in settings is one for all of the app's users. render receives the chosen option as
a plain string, since no type in your code can know the list. The platform has already checked
it against the list settings returned; a value saved earlier that the list no longer has reads
as unset.
How long settings last
The platform keeps what settings returned for 24 hours, or
for settingsTtlMs when the app sets it, and every display's render reads its values against that
copy.
When you upload a version, the store runs settings and keeps what it returned with the version.
If settings fails later, for example because the service it reads is down, the platform uses
that copy, so displays keep working. If it fails during the upload, the upload fails.
The platform checks what settings returns before it uses it: a default must fit its type and
rules, min and max belong to numbers, min_length and max_length to strings, and options
to selects. An upload whose settings break a rule fails; the local preview prints the reason in
the terminal where it runs.
What people see
People fill in the settings in the LEDABLE app when they add your app to a display, and can change
them later from the display's playlist. The form lists the settings in the order settings
returns them, switches in a section of their own below the rest. Each shows its name, its
description under it, and a mark when it is required. The app cannot be added while a required
setting is empty or a value does not fit its rules, and the form names those settings.
- A
selectwith more than five options becomes a list people can search; acountryand atimezonealways are. - A
locationis chosen by searching for a city. - A colour is picked from a small palette, which includes your default.
The form of the local preview applies the same rules, so what installs there installs on a phone.