Skip to content

Theme clocks

Theme Engine V1 is available on every display target except the lean ESP8266 image (smalltv_lean), which drops it to keep the heap headroom it exists to protect. A theme is data and assets; it never contains executable code. Once the firmware supports themes, creating another clock face requires only theme files.

  1. Open Display → Theme clocks in the device web UI.
  2. Choose a .stheme file and click Install .stheme.
  3. Choose the installed theme and click Use theme. Selection survives reboot.
  4. Set the timezone under Clock & night mode. Theme mode starts NTP even when night mode is off. Unsynchronized time fields display --.

The repository includes examples/themes/pixel-room.stheme, a complete example with a background, animated cat, time, and date. Its source assets and manifest are in examples/themes/pixel-room/. examples/themes/live-status.stheme demonstrates scrolling text and fetched-data bindings; see Example: a dynamic dashboard.

SmallTV Studio is the visual editor for theme clocks. It runs in the browser with nothing to install; the source is in its own repository.

SmallTV Studio editing the Pixel Room theme

  • Text, image, animation, and shape layers on a live 240×240 canvas
  • A timeline with a simulated date and time, and a preview of the clock before its first NTP sync
  • Data sources and bindings, with preview values for the fields
  • Export as a .stheme package or as a source folder that tools/smalltv_theme.py build turns into the same package
  • Send to TV installs the theme on the device over its API and can activate it

Packages exported by the editor pass the firmware’s validator unchanged. This firmware repository keeps the format, validator, packer, and offline preview tools so themes remain reproducible in CI without the editor application. The rest of this page documents the format for hand-written themes and the command-line tools.

SmallTV Pomodoro, a separate alternative firmware for the SmallTV-ultra, has its own installable clock faces. The two projects are working on a shared theme format, so that one .stheme package runs on both firmwares (#31). Until that lands, packages for one firmware do not install on the other.

Notifications keep their existing behavior and cover the active theme. When the notification ends, the theme is restored with the current time and animation frame. Theme mode can be selected directly or ticked as Theme clock under Carousel, where it rotates with the other features. The tick is off by default: with no theme installed the slot would only show Select an installed theme.

Use Remove to delete an inactive theme. To replace the currently selected package, first choose another display mode and Save settings, remove the old package, then install the replacement. Duplicate installed IDs are rejected. Unreadable or invalid packages remain listed with their filename ID, size, and validation error. Use theme is disabled for them, but Remove remains available, including when a corrupt package was selected. Removing that package clears the saved selection; install or select a valid theme to resume the clock. Up to 16 packages can coexist, subject to available filesystem space. Firmware updates do not require reinstalling packages. Settings export contains the selected ID, not the package files; keep your source themes and .stheme files separately.

Installing a package also records its name, author, version and size in one index file for all themes, /themes/.index (one line per theme; one file, since every file takes at least one 8 KB block of storage on the ESP8266). The list reads that index instead of validating every package again, so it never needs to free the running theme. A package without a matching line (installed by an older firmware, or changed since) is validated once, the next time the list is opened, like any other package parse; a package that fails is listed with its error and checked again each time. Selecting a theme always runs the full validation. The <id>.meta files that firmware 3.1.0 and 3.1.1 wrote, one per theme, are removed when the device starts.

The repository includes a skill in the Agent Skills format, skills/smalltv-theme-clock. Describe the clock you want. The agent asks a few questions (digital or analog, 12 or 24 hours, date, live data, colors), writes the source folder, and builds the .stheme with a PNG and animated GIF preview. Where it runs on your network, it can also install the theme on the TV.

To use it, copy the skills/smalltv-theme-clock folder into your agent’s skills directory, or zip it (one top-level smalltv-theme-clock/ folder) for apps that take skill uploads.

The skill ships its own pure-Python packer, validator and renderer (Python 3.9+ and Pillow), so it needs no compiler or PlatformIO. Its packages are byte-identical to tools/smalltv_theme.py build and its previews match the firmware renderer pixel for pixel. It uses spec 1 unless the design needs a spec 2 feature. Its default palette is the one from examples/themes/live-status.

pixel-room/
├── theme.json
├── images/background.png
├── animations/cat/000.png
├── animations/cat/001.png
└── fonts/ (spec 2: fonts, see Fonts in the package)

The desktop tools require Python 3.10+, Pillow and a C++11 compiler (c++, g++, or clang++; set CXX if needed). On Linux/macOS/WSL, prepare the dependencies:

Terminal window
python3 -m venv /tmp/smalltv-theme-venv
source /tmp/smalltv-theme-venv/bin/activate
pip install -r tools/requirements-theme.txt
pio pkg install -e smalltv_esp32_8mb

PlatformIO provides the ArduinoJson headers and built-in font used by the firmware. An existing firmware build already has them. No device or firmware recompilation is needed: the native desktop adapter compiles automatically on first use and is cached under .pio/theme-host/. Alternatively, point SMALLTV_ARDUINOJSON_INCLUDE and SMALLTV_GFX_INCLUDE at library source directories containing ArduinoJson.h and font/glcdfont.h.

Validate, build and preview a theme:

Terminal window
python3 tools/smalltv_theme.py validate examples/themes/pixel-room
python3 tools/smalltv_theme.py build examples/themes/pixel-room /tmp/pixel-room.stheme
python3 tools/smalltv_theme.py validate /tmp/pixel-room.stheme
python3 tools/smalltv_theme.py preview /tmp/pixel-room.stheme /tmp/pixel-room.html \
--time 2026-09-18T10:24:55 --seconds 10 --fps 15

validate accepts a source directory or an installed-format .stheme. Both paths use the firmware’s actual C++ validation, including every compiled frame. Source validation also checks asset decoding and safe relative paths. Errors identify the field or asset, for example layers[0].color: expected a color in #RRGGBB form. build validates before writing its output. The original python3 tools/theme_pack.py SOURCE OUTPUT.stheme command remains supported and now performs the same complete validation.

Open the generated HTML in a browser. It contains the images and controls needed for offline playback, pause, seeking and replay. A ready-made example lives at examples/themes/pixel-room-preview.html; simply open it, without installing tools. Preview generation accepts source directories too. It uses the same C++ compositor, RGB565 blending and bitmap font as the device, and advances time across day/month/year boundaries. --time is the desired local wall time without a UTC offset, defaulting to the computer’s current wall time. Duration is 1–60 seconds, sampled at 1–15 FPS. The recorded clip stops at its end; Replay restarts it. A lower preview sampling rate can skip intermediate animation frames. Panel correction, hardware latency and notifications are not simulated. The preview’s playback controls are desktop HTML, never included in .stheme packages.

A theme that declares data fields has nothing to fetch on a desktop, so preview generation accepts repeatable --data source.field=value arguments and holds those values for every sampled frame instead:

Terminal window
python3 tools/smalltv_theme.py preview examples/themes/live-status \
examples/themes/live-status-preview.html --seconds 8 --fps 15 \
--time 2026-09-22T12:00:00 \
--data status.label='SmallTV dynamic dashboard headline' \
--data status.level=82 --data status.state=Warning

Each key must reference a field declared in the theme’s data block, exactly once; an unknown key, a missing =, an empty key, a duplicate key, or more than 32 values fails the command before it writes any output. Injected values only affect the preview frames; they cannot change package validation or which assets are read. examples/themes/live-status is the reference fixture for this workflow — see Example: a dynamic dashboard.

This utility converts image pixels on your computer. The device reads RGB565 assets directly; it does not decode PNG/GIF/TTF. The package stores the same manifest values, including logical source paths. A future compiler can replace the asset provider without changing those paths. GIF extraction and advanced compiler features are not implemented in V1. Images are stored compressed (STI2, see Device package format) only in a theme with "spec": 2, and only such a theme can carry its own fonts, which the packer compiles from TrueType files.

{
"spec": 1,
"theme": {"id": "minimal-clock", "name": "Minimal Clock", "author": "You", "version": "1.0.0"},
"display": {"width": 240, "height": 240, "background": "#000000"},
"layers": [
{"id": "clock", "type": "text", "x": 120, "y": 100, "anchor": "center", "value": "{HH}:{MM}", "size": 48, "color": "#ffffff"},
{"id": "date", "type": "text", "x": 120, "y": 150, "anchor": "center", "value": "{WD} {DD} {MON}", "size": 16, "color": "#aaaaaa"}
]
}

All fields shown at the root, in theme, and in display are required. spec is 1 or 2. Spec 2 lets the packer store images as STI2, which is much smaller for images with transparent areas or few colours. Firmware up to 3.1.1 refuses a spec 2 theme; when it has STI2 images, the message there is “Invalid or truncated .stheme package”, because that firmware checks the images before it reads the manifest. A spec 1 theme packs exactly as before. Spec 2 also lets a binding read a built-in value. The canvas is always 240×240 with the origin at the top left. Coordinates and sizes are integer pixels. Layers draw in array order, back to front; IDs must be unique. Unknown fields, unsupported types, invalid values, and unknown text variables are rejected. Nothing evaluates expressions or runs scripts.

A theme may declare up to four small JSON sources. The firmware fetches each URL periodically and exposes only the declared fields to text layers. A response is limited to 2 KiB, and a source can declare at most eight fields. The minimum refresh interval is 10 seconds.

One request is in flight at a time, and every request is bounded by a three-second deadline that header and body reads also check. On the ESP32 boards the fetch runs in a background task, so a stalled or slowly trickling response cannot hold up the display loop; the task’s 8 KiB stack exists only while fetching, and connect and TLS handshake timeouts are also three seconds. The ESP8266 has no FreeRTOS, so there the fetch is one blocking call in the main loop, like the ticker and radar fetchers, and the display waits for it within the same deadline. An HTTPS fetch is skipped while free heap is below 18,000 bytes and counts as a failed request. Switching themes cancels the old request and discards its result. Failed requests keep the last displayed values until the next scheduled attempt. When a value changes, only text whose rendered content changed is repainted, including the area needed to erase its previous contents.

{
"data": [
{
"id": "weather",
"url": "https://example.local/weather.json",
"interval": 300,
"insecureTls": true,
"fields": [
{"id": "temp", "path": "main.temp"},
{"id": "city", "path": "name"}
]
}
],
"layers": [
{"id": "reading", "type": "text", "x": 120, "y": 90,
"anchor": "center", "value": "{weather.city} {weather.temp}C",
"size": 16, "color": "#ffffff"}
]
}

The path is a dotted path through JSON objects. Values are rendered as short strings; missing values show --. URLs must use http:// or https://. Because this path cannot validate server certificates, an HTTPS source must explicitly set "insecureTls": true; omitting it rejects the package instead of silently accepting unauthenticated TLS. Data fetching is owned by theme mode and does not affect notifications or other display modes.

Installing a theme installs its data sources too: the device will periodically poll every URL the manifest declares, including addresses on your LAN. Only install themes from sources you trust — a manifest’s data block is not sandboxed beyond the request limits above (2 KiB response, one in-flight request, three-second deadline).

Fetched values are scalars used as-is: to interpolate into text (above), or to drive a layer’s position, size, or color through the bindings below. There are no history buffers, line charts, arrays, aggregation, arithmetic expressions, or conditions in V1 — a fetched number maps to a property through exactly one declared, bounded rule.

Every layer needs id, type, x, and y. The device clips geometry outside the canvas. Colors are exactly #RRGGBB, case insensitive; asset alpha is supported.

Required fields: value, color, and size unless a spec 2 layer names a font instead (see Fonts in the package). Optional anchor defaults to top-left. Anchors position the complete text cell rectangle:

Vertical position Left Center Right
Top top-left top-center top-right
Center center-left center center-right
Bottom bottom-left bottom-center bottom-right

V1 uses the built-in ASCII bitmap font. size is the exact cell height in pixels, 8–96; cell width is ceil(size × 6 / 8). No wrapping or multiline text. Sizes 8, 16, 24, etc. give uniform pixel scaling. Text is transparent over lower layers. Month and weekday names are English in a "spec": 1 theme; the device’s configured timezone applies. In a "spec": 2 theme they follow the device language (see Localized text).

Token Value
{HH} 00–23 hour
{hh} 01–12 hour
{MM} 00–59 minute
{SS} 00–59 second
{DD} 01–31 day
{MON} / {MONTH} Abbreviated / full month
{WD} / {WEEKDAY} Abbreviated / full weekday
{YYYY} Year

A theme with "spec": 2 also accepts these tokens, so one text can use the union of what this firmware and SmallTV Pomodoro offer. They need firmware v3.4.0 or later:

Token Value
{H} 0–23 hour, no leading zero
{MO} 01–12 month number
{AMPM} AM for 00:00–11:59, PM for 12:00–23:59
{device.focusToday}, {device.focusGoal}, {device.streak}, {device.humidity}, {device.temperature} The firmware’s value, as text, exactly as it supplied it

{hh} is the 12-hour clock with a leading zero, so {hh}:{MM} {AMPM} reads 03:04 PM; {H}:{MM} reads 15:04. Like the other clock tokens, the three clock tokens show -- until the clock has been set. A {device.*} token shows -- when the firmware does not supply that value, which is always the case on this firmware today (see Built-in sources), so a layout that must also run here should leave room for --. The names are the same as for bindings; time.* is not a text token, because {HH}, {MM} and the other clock tokens already cover it.

In a "spec": 1 theme none of these is accepted: {H}, {MO}, {AMPM} and {device.*} are unknown variables, so the package is refused when it is checked with layers[n].value: use printable ASCII and declared clock/data variables only, the same as any other unknown {...}. (A spec 1 theme that declares its own data source called device keeps using {device.<field>} as a fetched field, as before.) tools/smalltv_theme.py preview --data device.temperature=21 supplies a device value for a spec 2 theme, to see what the layer shows with and without it.

Localized text needs firmware v3.4.0 or later. The device has a language setting (Settings, Clock & night mode, Language; clock.lang in the settings file). It takes en, pt, es, fr, de or it and defaults to en. In a "spec": 2 theme:

  • {MON}, {MONTH}, {WD} and {WEEKDAY} come out in that language. The names are plain ASCII, because the built-in font is (Portuguese Marco, Terca-feira, Spanish Miercoles, French Fevrier, German Maerz), and the abbreviations come from tables, not from cutting the name (French Juin and Juil, Sept; Portuguese Set; German Son, Mon). A "spec": 1 theme keeps English names, whatever the device language.
  • A text layer’s value may be an object with one string per language instead of a single string:
{"id": "greeting", "type": "text", "x": 120, "y": 60, "anchor": "center", "size": 16, "color": "#ffffff",
"value": {"en": "Today is {WEEKDAY}", "pt": "Hoje e {WEEKDAY}", "es": "Hoy es {WEEKDAY}"}}

en is required and is shown for any language the object does not list, so a theme never shows an empty layer. The other keys are the codes above, in lower case; any other key is an error. Every string follows the rules of a plain value (1–128 printable ASCII bytes, only declared variables), is checked when the package is installed, and may use any variable. The object has at most six entries, one per language, and the non-English texts of a whole theme share 1,024 bytes of RAM: each counts 28 bytes and, past 15 characters, its text too, so that is about 36 short ones or 6 of 128 characters (the English ones are not counted). In a "spec": 1 theme an object is refused with the layer named, so the package never installs half-working. Changing the device language redraws the text at once.

smalltv_theme.py preview takes --lang CODE to preview a language (default en). Text on a layer whose size or position depends on the language is up to the author: leave room for the longest string.

Fonts need firmware v3.6.0 or later. A theme with "spec": 2 can bring its own fonts, antialiased over the layers below. A text layer names one with font and gives no size: the font’s line height is the layer’s height. Layers without font keep the built-in font.

{"id": "time", "type": "text", "x": 120, "y": 106, "anchor": "center",
"value": "{HH}:{MM}", "font": "clock", "color": "#ffb000"}

The packer compiles fonts/clock.json in the source theme into fonts/clock.stf in the package, from a TrueType file next to it:

{"file": "BebasNeue-Regular.ttf", "size": 80, "glyphs": "0123456789:"}
Field Value
file A TrueType (.ttf, glyf outlines) file in fonts/
size Pixels per em, 4–200
glyphs The printable ASCII characters to keep, for example "0123456789:"
fallback Optional: one of glyphs, or -, which every font keeps; drawn for any character the font lacks. Default ? when kept, else -

Keep only the characters the theme shows: each costs flash in the package, not RAM on the device. For a clock face, the digits and : are enough; for {WD} and {MON}, add the letters of the names in each language the device may be set to (see Localized text), and of every string of a localized value. A character the font lacks, including any byte a fetched value brings outside printable ASCII, is drawn as the fallback glyph. Every font keeps -, which need not be listed: the device draws -- for a value it does not have yet, such as {HH}:{MM} before the clock is set. The packer refuses a text layer whose text outside {tokens} has a character its font lacks. The line box fits the ink of the glyphs kept, so anchor places what is drawn; some of that ink must reach above the baseline. Each glyph’s ink lies within its advance: the compiler widens a glyph’s advance rather than cut off ink that reaches past it. The compiler reads the outlines itself, without FreeType, so the same font and settings give the same bytes on any computer. It does not apply hinting or kerning. To check a font on its own:

Terminal window
python3 tools/theme_font.py fonts/BebasNeue-Regular.ttf /tmp/clock.stf --size 80 --glyphs 0123456789:

A theme may use up to four fonts, and a package with more than four .stf entries is refused. The device keeps one byte per glyph in RAM (its advance, to lay out text) and reads the glyph bitmaps from the package while it draws, one glyph row at a time. A theme with a font layer also keeps the last 16 glyph records it read (128 bytes, allocated when the theme is loaded), so each row of a text reads only the bitmaps. Checking a font reads it in 64-byte pieces and yields to the system after every glyph; the largest font the format allows is about 1,040 reads.

Fonts carry their own licence: use fonts whose licence allows embedding, such as the SIL Open Font License, and ship the licence text with the theme source. The package itself holds subsets of the font, so name the font, its copyright holder and its licence in the theme’s author, which travels with the package. examples/themes/bebas-clock uses Bebas Neue (OFL, fonts/OFL.txt) in two sizes, with "author": "smalltv-mod; font Bebas Neue (c) Dharma Type, OFL"; its package is 14,240 bytes.

Firmware before 3.6.0 refuses a package with fonts with “Invalid or truncated .stheme package”. A spec 1 manifest with font is rejected with layers[N].font: needs "spec": 2, and a spec 1 package with an .stf entry with fonts/<name>.stf: font needs "spec": 2. A font whose baseline is 0 or below the line box is refused with fonts/<name>.stf: font baseline must be 1 to the line height.

A text layer whose rendered content is wider than a declared viewport can scroll horizontally, instead of being clipped:

{"id": "headline", "type": "text", "x": 20, "y": 40, "value": "{status.label}",
"size": 12, "color": "#ffffff",
"scroll": {"width": 200, "mode": "loop", "speed": 30, "pause": 1200, "gap": 24}}
scroll field Type Range Required Meaning
width integer 1–240 px yes Width of the clipping viewport
mode string "mode": "loop", "mode": "bounce" yes Motion behavior
speed integer 1–240 px/s yes Horizontal speed
pause integer 0–10000 ms no, default 1000 Hold time at the initial position, and for bounce, at both ends
gap integer 0–240 px loop only, default 24 Blank space between repeated copies

gap is rejected in bounce mode; it has no effect there. x, y, and anchor position the viewport, not the complete unscrolled string — x: 120 with anchor: center centers the 200px viewport at x=120, not the full text. The viewport height is size; its width is scroll.width. It clips to the 240×240 canvas like any other layer, and text is additionally clipped to the viewport even when a wider dirty region from another layer overlaps it.

If the rendered text fits within the viewport, it stays fixed at its initial position and never schedules a repaint. Otherwise scrolling starts after pause milliseconds. In loop mode, the text moves continuously in one direction; once it has scrolled past its own width plus gap, it wraps back to the start and pauses again — the gap is where a second, repeated copy would begin. In bounce mode, the text moves to its far edge, pauses, reverses, moves back to the start, and pauses again, alternating direction on every cycle. Timing uses elapsed milliseconds and a subpixel accumulator, the same technique frame animation already uses; a late update jumps directly to the due integer pixel offset rather than replaying intermediate frames, and 32-bit millisecond wraparound is handled correctly. A display write is scheduled only when the visible integer pixel offset actually changes.

Changing the expanded text, or a binding that affects size, scroll.width, or scroll.speed, resets scroll position, phase, and direction to their initial state and restarts the pause. Any other change — an unrelated data value, a binding affecting x or color, or calling the engine’s notification-recovery invalidate() — does not reset scroll progress; switching themes does.

{"id":"background","type":"image","x":0,"y":0,"source":"images/background.png"}

Dimensions come from the asset, between 1×1 and 240×240. No runtime scaling. PNG alpha is preserved and composited against the layers below it.

{"id":"box","type":"shape","shape":"rectangle","x":20,"y":20,"width":200,"height":60,"cornerRadius":8,"fill":"#102030","stroke":"#ffffff","strokeWidth":2}
{"id":"ring","type":"shape","shape":"circle","x":120,"y":120,"radius":110,"stroke":"#ffffff","strokeWidth":2}
{"id":"line","type":"shape","shape":"line","x":20,"y":180,"x2":220,"y2":180,"stroke":"#ffffff","strokeWidth":2}

Rectangles use a top-left origin; circles use a center and radius. Rectangle and circle strokes are inside their outer bounds. Lines use two endpoints, with a centered stroke and round ends. strokeWidth defaults to 1 and supports 1–32. Rectangles and circles need fill, stroke, or both; lines need stroke.

A rectangle’s cornerRadius is optional, defaults to 0 (square corners), and has a static manifest range of 0–120 px. After any bindings resolve, the effective radius is clamped to floor(min(width, height) / 2), so a rectangle that shrinks (through a bound width or height) never draws rounding outside its own bounds. Fill occupies the rounded outer shape; stroke occupies the ring between that outer shape and an inner shape whose radius is max(0, cornerRadius - strokeWidth). A dynamic width or height that resolves to zero makes the rectangle temporarily invisible, regardless of cornerRadius.

Three more shapes exist only in a theme with "spec": 2, on firmware v3.4.0 or later. A spec 1 theme that uses one is refused with arc, hand and ticks need "spec": 2, and a spec 1 theme installs exactly as before. All three are drawn in stroke alone, with no fill, and take an optional strokeWidth of 1–32 (default 1).

{"id":"progress","type":"shape","shape":"arc","x":120,"y":120,"radius":100,"start":0,"sweep":270,"stroke":"#47c2ff","strokeWidth":10}
{"id":"minute","type":"shape","shape":"hand","x":120,"y":120,"length":90,"tail":20,"angle":0,"stroke":"#ffffff","strokeWidth":4}
{"id":"dial","type":"shape","shape":"ticks","x":120,"y":120,"radius":112,"count":12,"length":10,"stroke":"#8a8a8a","strokeWidth":3}

Angles are whole degrees, 0 at 12 o’clock and growing clockwise, so 90 is 3 o’clock and a negative angle goes the other way (−90 is 9 o’clock).

  • arc draws a ring segment about the center x, y: the outer edge is radius and the band is strokeWidth wide inside it, like a circle’s stroke. It starts at start (−360 to 360) and runs sweep degrees (−360 to 360) clockwise, or counter-clockwise when sweep is negative. A sweep of 360 or more is a full ring and a sweep of 0 draws nothing, so a progress ring at 0 % is simply empty. A strokeWidth of at least radius fills the wedge. The ends are flat and radial. radius is 1–240.
  • hand draws a line through the pivot x, y, from tail (0–240, default 0) behind it to length (1–240) in front, in direction angle (−360 to 360), with a centered stroke and round ends like line.
  • ticks draws count (1–60) evenly spaced radial marks around the center x, y, the first at 12 o’clock. Each runs length (1–240, stopped at the center) inward from radius (1–240), with round ends.

sweep on an arc, angle on a hand and radius on an arc or ticks can be bound (see Dynamic bindings), with an output of −360 to 360 for sweep and angle. A minute hand is angle bound to the minute with input [0, 60] and output [0, 360]; a progress ring is sweep bound to a percentage with input [0, 100] and output [0, 360]. Nothing else about these shapes moves: start, length, tail and count are fixed.

Everything is drawn with integers, with no framebuffer and no table: a pixel is placed to within about a pixel of the exact geometry. When a bound angle or sweep changes, only the shape’s old and new bounding boxes are repainted, so a single hand repaints two small boxes, not the dial behind it.

{"id":"cat","type":"animation","x":160,"y":170,"width":64,"height":64,"source":"animations/cat","frames":12,"fps":8,"loop":true}

All shown fields are required. Source frames are 000.png, 001.png, etc.; their dimensions must exactly match width and height. Each animation has its own elapsed-time schedule, independent of clock text. Late rendering skips to the due frame instead of queuing work. loop:false holds the final frame. Pausing under a notification catches up when the theme becomes visible. Switching themes restarts the animation. Only the required pixel rows are read, never a whole animation. The renderer resolves each visible image/frame once per render pass and reuses its asset handle across rows and dirty regions.

bind is an optional object on any layer, keyed by a supported property name. Each key holds exactly one numeric mapping or color-stop binding, driven by a fetched scalar. A binding’s source is the same declared <data-source-id>.<field-id> token accepted by text interpolation or, in a "spec": 2 theme, a built-in source. Bindings are recomputed from the parsed theme and the latest fetched values whenever those values change (and, for a binding on time.*, whenever the clock moves); the parsed manifest itself is never mutated. Only the changed region is repainted — the union of the layer’s old and new visible bounds.

{"bind": {"width": {"source": "status.level", "input": [0, 100], "output": [0, 200], "clamp": true}}}
Field Type Required Rule
source string yes A declared data field or, in spec 2, a built-in source
input two finite numbers yes Endpoints must differ; ascending or descending is allowed
output two integers yes Both must fit the target property’s safe range below
clamp boolean no, default true Clamp to the output endpoints when true

The mapping is linear:

output0 + (source - input0) * (output1 - output0) / (input1 - input0)

The result is rounded to the nearest integer, halves away from zero. When clamp is true, the source value is limited to the interval between the two input endpoints before interpolation. When clamp is false, extrapolation beyond that interval is permitted, but the final integer is still clamped to the property’s safe range below — that clamp cannot be disabled. A missing, partial, or non-finite fetched value leaves the static manifest value in effect for that property, so a theme always has a valid fallback appearance.

Property Safe range
x, y, x2, y2 −240 through 479
width, height, radius 0–240
cornerRadius 0–120, then limited to half the resolved rectangle size
size 8–96
strokeWidth 1–32
scroll.width 1–240
scroll.speed 1–240
{"bind": {"fill": {"source": "status.level",
"stops": [{"at": 0, "value": "#35c46a"}, {"at": 60, "value": "#f0b429"}, {"at": 80, "value": "#e5484d"}]}}}
Field Type Required Rule
source string yes A declared data field or, in spec 2, a built-in source
stops array yes 1–8 entries
stops[].at finite number yes Strictly increasing
stops[].value #RRGGBB yes Existing color syntax

The selected color is the last stop whose at is less than or equal to the fetched value; a value below the first stop’s at uses the first stop’s color. Every finite fetched number therefore has a color, with no separate default branch. A missing or non-finite fetched value leaves the static manifest color in effect.

Each property may be bound once; a layer may bind at most eight properties. A color property may be bound only when that property already exists statically on the layer — binding stroke does not implicitly enable a missing stroke, and scroll.width/scroll.speed require a static scroll object. Image and animation dimensions are not bindable, because runtime asset scaling is unsupported and their declared size must match packaged assets.

Layer Numeric properties Color properties
text x, y, size (not with a font), scroll.width, scroll.speed color
rectangle x, y, width, height, cornerRadius, strokeWidth fill, stroke
circle x, y, radius, strokeWidth fill, stroke
line x, y, x2, y2, strokeWidth stroke
arc (spec 2) x, y, radius, sweep, strokeWidth stroke
hand (spec 2) x, y, angle, strokeWidth stroke
ticks (spec 2) x, y, radius, strokeWidth stroke
image x, y none
animation x, y none

An inapplicable property, a duplicate binding target, an unknown binding key, or a source that is not a declared data field rejects the package, for example layers[0].bind.width: binding is not applicable to this layer or layers[0].bind.width.source: expected a declared data field. Invalid scroll fields are diagnosed the same way, for example layers[0].scroll.mode: expected loop or bounce or layers[0].scroll.gap: not allowed in bounce mode.

A theme with "spec": 2 may name a value the firmware already has instead of a fetched field, and everything above applies to it unchanged: the same numeric mapping and clamping, the same color stops, the same fallback to the layer’s static value when there is no value. Use them exactly like a data field, for example a seconds bar that needs no data block:

{"id": "sec", "type": "shape", "shape": "rectangle", "x": 20, "y": 200, "width": 1, "height": 6, "fill": "#ff5a47",
"bind": {"width": {"source": "time.second", "input": [0, 59], "output": [0, 200]}}}
Source Range Meaning
time.second 0–59 Second of the minute
time.minute 0–59 Minute of the hour
time.hour 0–23 Hour, 24-hour clock
time.hour12 0–11 Hour, 12-hour clock (midnight and noon are 0)
time.secondOfDay 0–86399 Seconds since midnight
time.day 1–31 Day of the month
time.month 1–12 Month, January is 1
time.weekday 0–6 Day of the week, Sunday is 0
time.moon 0–29 Age of the moon in whole days: 0 is the new moon, 14–15 the full moon

time.moon needs no network. It counts the time since a reference new moon, 2000-01-06 18:14 UTC, modulo the mean synodic month of 29.530588 days (29 d 12 h 44 min 3 s), and drops the fraction. The result is 0 to 29, and it steps up by one every 24 hours, then falls from 29 to 0 at the next new moon. Because it uses the mean month, the real moon is up to about 0.75 days early or 0.4 days late: checked against the US Naval Observatory’s times for 150 new and full moons from 2020 to 2030, a true new moon reads 28, 29 or 0 and a true full moon reads 14 or 15. Use input: [0, 29] and treat 28 to 1 as new and 13 to 16 as full if a theme needs a safe margin. The value is computed from the clock in the configured timezone as if it were UTC, which adds up to the offset (at most about half a day) to that error. Before the clock is set there is no value.

time.* follows the same clock as {HH} and the other date and time text variables, in the configured timezone. Until the clock has been set (no NTP yet) there is no value, so the layer keeps its static value. A layer bound to a time.* source repaints only when the bound property actually changes: a time.second bar repaints its own rectangle once a second, a time.minute bar once a minute, and neither makes the rest of the canvas redraw.

device.* sources are optional values a firmware supplies from something the theme engine itself does not know about. The names are fixed: device.focusToday, device.focusGoal, device.streak, device.humidity and device.temperature. A firmware that does not supply one leaves the layer on its static value, exactly like a fetched field that is missing, so one theme can run on firmwares with and without it. SmallTV Pomodoro supplies all five. This firmware has no sensor and no focus timer, so it supplies none of them today; the engine takes them from the firmware through Engine::setDeviceValues() and they are always strings holding a number, like fetched fields. A device.* value is never read from a theme’s data block, and a fetched field cannot shadow one.

In a "spec": 1 theme these names are refused when the package is checked, with layers[0].bind.width.source: time.* and device.* sources need "spec": 2. Spec 1 keeps its rule that source must be a declared data field. In spec 2, time and device can no longer be used as the id of a data source, since time.second would then be ambiguous. A name that starts with time. or device. but is not in the tables is rejected with unknown time.* or device.* name.

Data age needs firmware v3.6.0 or later. In a "spec": 2 theme every data source also has an age field that the theme does not declare: w.age for a source with the ID w. It is the time since the source’s last successful fetch, in whole minutes (0 up to 71582, about 49.7 days, where it stops). Use it like any fetched field, in a binding or in text ({w.age}), for example to mark data that has gone stale.

  • Before the first successful fetch the age is unknown: there is no value. A binding keeps its static value and text shows --, exactly like a fetched field that is missing.
  • A successful fetch sets the age to 0. A failed one (no connection, an HTTP status other than 200, a reply that is cut off, too large or not JSON) changes nothing, so the age keeps growing from the last success. A reply that is valid JSON but lacks a declared field is still a success; that field shows --.
  • The age is the time since the fetch ended, not the age of the data at the server. It counts the device’s run time, not the clock, so it works before the clock is set, and a change of the time or timezone does not move it.
  • It is a whole number that goes up once a minute, so a layer bound to it repaints at most once a minute. After a restart it is unknown again.
  • A source that declares a field of its own called age keeps that field; no built-in age is added to that source.
  • In a "spec": 1 theme w.age is refused like any name that is not a declared field.

examples/themes/live-status builds examples/themes/live-status.stheme from a theme declaring one data source (status, with label, level, and state fields) and every capability above:

  • headline — a loop-scrolling {status.label} text viewport;
  • headline-bounce — a second, bounce-scrolling label, so both scroll modes are copyable side by side;
  • level-track and level-bar — a rounded track and a bar whose width maps fetched level 0–100 to 0–200 px, with fill selected from green/amber/red color stops at the same thresholds;
  • level-caption — status text whose color is bound from the same numeric status.level field, through color stops rather than a numeric mapping.

Every layer also has valid static fallback geometry and colors, so the theme produces a correct first frame even before any fetch completes. Rebuild and preview it with the --data workflow documented above.

Item V1 limit
Manifest 16 KiB, nesting depth 8
Layers 32
IDs 1–48 ASCII letters, digits, _, -
Theme name / author / version 96 / 96 / 32 bytes
Text template 1–128 printable ASCII bytes
Localized text Up to 6 languages, 1–128 printable ASCII bytes each, en required; 1,024 bytes of non-English text per theme; spec 2 only
Coordinates −240 through 479, clipped to the canvas
Rectangle/image/frame dimensions 1–240 pixels
Circle radius 1–240 pixels
Arc / ticks radius 1–240 pixels
Arc start, sweep; hand angle −360 to 360 degrees
Hand length / tail; tick length 1–240 / 0–240 / 1–240 pixels
Tick count 1–60
Animation 1–240 frames, 1–15 FPS
Fonts (spec 2) 4 per theme; names 1–32 ID characters; printable ASCII glyphs; line height 6–96 pixels; advance 1–120 pixels; 65,535 bytes of glyph bitmaps
Container 256 entries, at most 3 MiB
Installed packages 16, within available storage

Packages live under /themes/<id>.stheme. Upload uses /themes/.upload; every entry, manifest, and referenced animation frame is checked before a rename makes the package visible. Failed or interrupted uploads leave existing themes and selection intact. Boot removes an abandoned staging file. Installation reserves space for settings and filesystem metadata. Settings saves use a temporary file, check the complete byte count, then rename over the old config. Reading, saving and importing settings free the running theme first when the heap is short, and a save that still runs out of memory fails with “not enough memory” and keeps the old settings.

Installing, selecting and removing a theme, and listing a package that has no line in the theme index yet, each check a package by loading it. When the free heap is short of what that needs (on the ESP8266, not on the ESP32 boards), the running theme is released first and loaded again afterwards, so only one theme is in RAM at a time. It then redraws once and its animations restart; its fetched values stay, and no data source is fetched before its interval is up. The manifest is read straight from the package and parsed one layer at a time, and no list in the engine needs one large block of memory; on the ESP8266 that is what makes this fit beside the web server.

The engine holds a bounded entry index and layer state in RAM. Compositing uses row buffers, not a 115,200-byte framebuffer. Static scenes produce no display writes after the first draw. Clock text is compared when wall time changes; animations invalidate only when their visible frame changes. Scrolling text schedules a write only when its visible integer pixel offset changes, and bindings are recomputed, and repainted, only when their fetched values actually change. Dirty areas are recomposited through all intersecting layers, restoring old glyphs, transparent sprite pixels, and foreground layers correctly, and a scrolling layer’s glyphs stay clipped to its own viewport even under a wider dirty region from another layer.

10–15 FPS and clock updates under 100 ms remain hardware performance targets. The automated tests exercise composition and scheduling; they do not measure LittleFS/SPI latency on an actual SmallTV Pro.

.stheme V1 is a small uncompressed binary container, not a ZIP file. All integers are unsigned little endian. No extracted paths become filesystem paths.

Container field Encoding
Magic Four ASCII bytes STH1
Entry count uint16, 1–256
Reserved uint16, zero
Each entry uint16 path byte count, uint32 payload byte count, path bytes, payload

Paths are relative ASCII letters/digits plus /, ., _, -, at most 120 bytes. Empty components, . and .., absolute paths, backslashes, and duplicates are rejected. There must be one theme.json. Other entries must be .sti images, or in a spec 2 theme fonts/<name>.stf fonts. No padding, trailing bytes, executable entries, or external asset references are accepted. JSON data-source URLs remain allowed through the manifest’s data block.

The asset resolver appends .sti to an image’s logical source, or resolves <animation source>/<zero-padded three-digit frame>.png.sti. For example, images/background.png maps to images/background.png.sti inside the package.

STI field Encoding
Magic Four ASCII bytes STI1
Width, height Two uint16, each 1–240
Alpha flag uint8: 0 for opaque, 1 for alpha
Reserved Three zero bytes
Pixels Row-major RGB565 uint16, followed by alpha uint8 if flag=1

Alpha 0 is transparent, 255 opaque. Intermediate values blend RGB565 channels with rounding. Opaque files use two bytes/pixel, transparent files three. The payload length must match the dimensions exactly.

A theme with "spec": 2 may also contain STI2 images, which store each row as runs. The packer writes whichever is smallest per image: STI1, STI2 with RGB565 colours, or STI2 with a palette of up to 16 colours. A spec 1 package with an STI2 image is rejected, with the image named.

STI2 field Encoding
Magic Four ASCII bytes STI2
Width, height Two uint16, each 1–240
Alpha flag uint8: 0 for opaque, 1 for alpha
Palette size P uint8: 0 for RGB565 colours, 1–16 for a palette
Reserved Two zero bytes
Palette P colours: RGB565 uint16, followed by alpha uint8 if flag=1
Row table One uint32 per row: where the row starts, counted from the end of the table
Rows Each row as runs

Offsets never go backwards; rows with equal offsets share their data. A row ends at the next larger offset, or at the end of the image. A run starts with one byte: its kind in the top two bits, its length minus one (1–64 pixels) in the low six:

Kind Run
00 Transparent pixels, no payload; only in an image with alpha
01 One colour repeated: RGB565 (+ alpha), or with a palette one index byte
10 That many colours: RGB565 (+ alpha) each, or with a palette two 4-bit indices per byte, high nibble first; the unused low nibble of an odd run is 0
11 Reserved; the image is rejected

The device checks every row when the package is installed or loaded: the runs of a row must cover its width exactly without reading past the row’s end, and every index must be below P. The shared test files in tests/theme/sti/ show every form with the pixels it must decode to.

A spec 2 theme may contain STF fonts, fonts/<name>.stf, which a text layer names with font. A font holds one 8-byte record for every code point from the first to the last it covers, so a glyph is found without a search, then the glyph bitmaps:

STF field Encoding
Magic Four ASCII bytes STF1
Line height uint8, 6–96 pixels: the height of a text layer in this font
Baseline uint8, 1 to the line height: the rows above the baseline. Checked, not yet used to draw
First code point uint8, 32–126
Glyph count uint8, 1–95; the last code point is at most 126
Fallback uint8: a code point with a glyph, drawn for any byte without one
Reserved Three zero bytes
Glyph records One per code point: see below
Bitmaps Each glyph’s bitmap, in record order
Glyph record field Encoding
Bitmap offset uint16, from the start of the bitmaps
Advance uint8, 1–120 pixels to the next glyph; 0 when the font has no glyph for this code point
Left, top Two uint8: where the ink starts, in the glyph’s advance and in the line
Width, height Two uint8: the ink’s size; left + width ≤ advance, top + height ≤ line height
Reserved One zero byte

A code point without a glyph has all eight bytes zero. A glyph without ink (a space) has an advance and zero offset, position and size. A bitmap is 4-bit coverage, 0 for none and 15 for full, two pixels per byte with the left one in the high nibble; each row starts on a whole byte, and an odd width leaves the low nibble of the row’s last byte 0. The bitmaps follow each other with nothing in between or after, so each offset is the sum of the bitmaps before it, at most 65,535 bytes in all.

The device checks every record and every bitmap row when the package is installed or loaded, so drawing can trust them. Coverage blends the layer’s colour over the pixels below it: c × 17 is the alpha of the RGB565 blend used for images. Compared with 2-bit coverage, 4 bits make the bitmaps about 1.8 times larger (Bebas Neue at 72 pixels per em, digits and :: 6,760 against 3,607 bytes) but give 16 levels of antialiasing instead of 4.

All routes use the device’s existing optional digest authentication, including upload callbacks. Responses are JSON; failures contain error.

Method and route Request / result
GET /api/themes All installed packages with valid, error, filename-derived id, metadata, bytes; selected ID, free bytes, renderer error
POST /api/themes/install One multipart file; returns 201 and { "id": "..." }
POST /api/themes/select JSON { "id": "..." }; validates, saves, activates, starts NTP
POST /api/themes/delete JSON { "id": "..." }; rejects deletion of a valid active theme; permits removal of a corrupt selected package

Invalid uploads return 400; duplicate IDs and valid active-theme deletion return 409; missing themes return 404. The normal settings JSON also exposes mode: "theme" and themeId. Prefer the selection endpoint because it validates the installed file before saving. An unavailable selected package displays an error while the web UI remains available for recovery.

After resolving PlatformIO’s dependencies:

Terminal window
sh tests/theme/run.sh
python3 -m unittest discover -s tests/theme -p 'test_*.py'
node tests/theme/test_webui.js
pio run -e smalltv -e smalltv_c2 -e smalltv_esp32 -e smalltv_esp32_wg -e smalltv_esp32_8mb -e smalltv_lean

The native suite uses the same C++ parser, scheduler, package reader, and compositor as the firmware. It also loads the example package, renders its frames over five minutes of simulated time, and writes /tmp/smalltv-theme-preview.ppm.

An optional real-browser check exercises offline playback, pause, seek and replay in the checked-in example. It needs Node 22+ and Chrome/Chromium:

Terminal window
node tests/theme/test_preview_browser.mjs
# Set CHROME=/path/to/chromium when the browser is not named google-chrome.