Commands, settings, SDK functions, anything in the docs.

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.

door-sign/index.tsx
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)] };
  },
});
The door-sign example on a LEDABLE panel

Field builders

BuilderWhat people setValue in paramsRules
stringTextstringmin_length, max_length
integerA whole numbernumbermin, max
floatA numbernumbermin, max
booleanA switchboolean
selectOne of a liststringoptions, or depends_on and options_by
multiselectAny of a listreadonly string[]options
dateA calendar day{ year, month, day }
timeA time of day{ hour, minute, second }
datetimeA momentDate
timezoneA time zonestring, such as Europe/Berlin
countryA countrystring, its two-letter code such as DE
locationA placeLocationValue: lat, lng, desc and more
colorRgbA colour[red, green, blue], each 0 to 255
colorRgbaA 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:

  • date takes "YYYY-MM-DD" and time takes "HH:MM:SS", as strings;
  • datetime takes 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:

TSX
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 hasWhen unset, params holdsType
required: trueNever unset: the app cannot be added until it is setthe value
defaultThe defaultthe value
Neithernullthe 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:

door-sign/index.tsx
    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:

door-sign/index.tsx
    // 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.

TSX
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 select with more than five options becomes a list people can search; a country and a timezone always are.
  • A location is 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.