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

Build apps

Data and the network

Fetch data from the hosts you declare, and check what comes back.

On this page

Your app fetches data with the global fetch, from the hosts it names in ledable.json. Check everything that comes back before you use it.

The preview, drawn from fixed values.
The preview, drawn from fixed values.
current-temperature/index.tsx
import * as z from "zod/mini";
import {
  cacheTag,
  defineApp,
  location,
  select,
  type LocationValue,
} from "@ledable/sdk/app";
import {
  Frame,
  Text,
  measureText,
  renderFrame,
  type BitmapFont,
} from "@ledable/sdk/graphics";
import { PIXOLLETTA, TINY, loadFonts } from "@ledable/sdk/fonts";

const FONT_REFS = { place: TINY, figure: PIXOLLETTA } as const;
type Fonts = { readonly [K in keyof typeof FONT_REFS]: BitmapFont };

const MINUTE_MS = 60 * 1000;

// Only the fields the app reads, under the names Open-Meteo gives them.
const forecastSchema = z.object({
  current: z.object({
    time: z.string(),
    temperature_2m: z.number(),
  }),
});

async function currentCelsius(place: LocationValue): Promise<number> {
  const query = new URLSearchParams({
    latitude: String(place.lat),
    longitude: String(place.lng),
    current: "temperature_2m",
  });
  const url = `https://api.open-meteo.com/v1/forecast?${query}`;
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`Open-Meteo answered ${response.status}`);
  }
  return z.parse(forecastSchema, await response.json()).current.temperature_2m;
}

/** `text`, shortened until it fits in `maxWidth` pixels. */
function fit(font: BitmapFont, text: string, maxWidth: number): string {
  // Whole characters, so a character made of two UTF-16 units stays whole.
  const characters = Array.from(text);
  const width = () => measureText(font, characters.join("")).width;
  while (characters.length > 1 && width() > maxWidth) {
    characters.pop();
  }
  return characters.join("");
}

function face(fonts: Fonts, place: string, degrees: string) {
  return renderFrame(
    <Frame width={64} height={32} background="#000000">
      <Text
        x={2}
        y={2}
        text={fit(fonts.place, place, 60)}
        font={fonts.place}
        color="#8a96a3"
      />
      <Text
        x={32}
        y={20}
        anchor="mm"
        text={degrees}
        font={fonts.figure}
        color="#ffffff"
      />
    </Frame>,
  );
}

export default defineApp({
  settings: () => ({
    place: location({
      name: "Place",
      description: "Where to read the temperature",
      default: { lat: 52.52, lng: 13.405, desc: "Berlin" },
    }),
    unit: select({
      name: "Unit",
      description: "Degrees Celsius or Fahrenheit",
      options: ["Celsius", "Fahrenheit"],
      default: "Celsius",
    }),
  }),

  async render(ctx, params) {
    let celsius: number;
    try {
      // One request per place for every display that shows it: the unit is
      // applied when drawing, so it is left out of the cache key.
      celsius = await ctx.cache.get(
        "current",
        async () => ({
          value: await currentCelsius(params.place),
          ttlMs: 15 * MINUTE_MS,
        }),
        { vary: ["place"] },
      );
    } catch (error) {
      // A failed fetch is not cached; the display keeps its last image.
      const reason = error instanceof Error ? error.message : String(error);
      return { ok: false, msg: `No temperature: ${reason}` };
    }
    const fahrenheit = params.unit === "Fahrenheit";
    const value = fahrenheit ? celsius * 1.8 + 32 : celsius;
    const degrees = `${Math.round(value)}°${fahrenheit ? "F" : "C"}`;
    const freshness = {
      softTtlMs: 15 * MINUTE_MS,
      hardTtlMs: 120 * MINUTE_MS,
    };
    // The text decides the picture, so it names it: the same id, the same
    // frames. A display that already shows it is told to keep it. The id
    // travels in an HTTP header, which cannot hold every character of a
    // place name, so it is a hash.
    const cacheId = await cacheTag(params.place.desc, degrees);
    if (ctx.cacheId === cacheId) {
      return { image: null, cacheId, ...freshness };
    }
    const fonts = await loadFonts(ctx.assets, FONT_REFS);
    const image = [face(fonts, params.place.desc, degrees)];
    return { image, cacheId, ...freshness };
  },

  async preview(ctx) {
    const fonts = await loadFonts(ctx.assets, FONT_REFS);
    return { image: [face(fonts, "Berlin", "13°C")] };
  },
});
current-temperature/ledable.json
{
  "app_id": "current-temperature",
  "version": "1.0.0",
  "entry": "./index.tsx",
  "name": "Current Temperature",
  "description": "The temperature outside a place you pick, from Open-Meteo.",
  "category": "weather",
  "network": { "allowed_hosts": ["api.open-meteo.com"] }
}

The example reads the current temperature from Open-Meteo, which needs no key. Its preview draws fixed values, because a preview may not use the network.

Declare the hosts

List every host your app requests in network.allowed_hosts:

  • api.example.com allows exactly that host.
  • *.example.com allows every host under example.com, at any depth, but not example.com itself; list both if you need both.
  • A wildcard may not cover a public suffix, a name that unrelated owners share: *.com and *.github.io are refused.
  • Write host names in lower case, without a scheme, port or path. IP addresses are not allowed.

An app that uses no network declares an empty list. People see the hosts in the store before they add your app.

Every request must be HTTPS to the default port, to a host on the list. Anything else fails in your app, and the error says why, for example:

Output
api.example.org is not among the app's network.allowed_hosts in ledable.json

The same rule applies in settings, in render, and in the local preview of ledable developer dev and ledable developer render, so a missing host shows up while you develop. fetch is the only way out: other connections, such as raw sockets, are refused.

Check what comes back

Treat every response as untrusted. Check the status, then parse the body with a schema, as the example does with zod and its small zod/mini build. Add zod to your project:

Terminal
npm install zod

Describe only the fields you read, under the names the API gives them, and parse with z.parse, which throws when the data does not match, so a changed or broken API fails the render instead of drawing nonsense. numericSchema from @ledable/sdk/app accepts a number that an API sends either as a JSON number or as a string, and gives you a number.

When a render fails

A render fails when it throws, or when it returns { ok: false, msg } with a message of your own, as the example does when the request fails. Either way, no new image is sent:

  • A display that already has an image from your app keeps showing it, and asks again every 15 seconds, until the image's hard TTL runs out (see Caching and refresh).
  • A display with no image from your app, or whose image has expired, shows its loading indicator while the entry is on screen, until a render succeeds.

So a failure costs nothing visible for as long as the last image is still good. Choose the hard TTL by how long old data is still worth showing.

ledable developer dev and ledable developer render show your message, or the error with the line of your code it came from. Values of your secrets and of people's credentials are replaced with *** in the messages and errors a render reports.

When you upload a version, the store renders it once with its default settings, unless a setting is required. If that render fails, for example because the API is down, the upload fails, and you can upload the same version again later (see Publishing checks).

Time

Keep a render short. When you upload, the store gives the preview and the render with default settings 10 seconds each, and refuses the upload if one takes longer. A display waits 15 seconds for the answer before it gives up and asks again later.

Ask other servers as little as you can: cache what they answer, and share it between displays where it is the same for everyone, as the example shares the temperature of a place.

What the platform answers

Some things your app needs are in its context and need no request:

  • ctx.now is the time of the request. Read the time only from it, never from Date.now() or new Date(), so that tests can fix it.
  • ctx.timezone is the display's time zone, an IANA name such as Europe/Berlin, and ctx.language its language.
  • ctx.locations.timezoneAt(lat, lng) returns the IANA time zone at a place, such as one a person picked in a location setting, or null when it finds none.
  • ctx.images.decode(bytes) decodes a PNG or JPEG you downloaded into a framebuffer; see Images and assets.

In the local preview, the answers of ctx.locations come from ledable.dev.json; see Local preview. Keys of your own go in secrets, and people's own accounts and tokens are covered in Accounts and credentials.