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.
Install and select
Section titled “Install and select”- Open Display → Theme clocks in the device web UI.
- Choose a
.sthemefile and click Install .stheme. - Choose the installed theme and click Use theme. Selection survives reboot.
- 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.
Design in SmallTV Studio
Section titled “Design in SmallTV Studio”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.
- 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
.sthemepackage or as a source folder thattools/smalltv_theme.py buildturns 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.
Create with an AI agent
Section titled “Create with an AI agent”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.
Create a source theme
Section titled “Create a source theme”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:
python3 -m venv /tmp/smalltv-theme-venvsource /tmp/smalltv-theme-venv/bin/activatepip install -r tools/requirements-theme.txtpio pkg install -e smalltv_esp32_8mbPlatformIO 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:
python3 tools/smalltv_theme.py validate examples/themes/pixel-roompython3 tools/smalltv_theme.py build examples/themes/pixel-room /tmp/pixel-room.sthemepython3 tools/smalltv_theme.py validate /tmp/pixel-room.sthemepython3 tools/smalltv_theme.py preview /tmp/pixel-room.stheme /tmp/pixel-room.html \ --time 2026-09-18T10:24:55 --seconds 10 --fps 15validate 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:
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=WarningEach 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.
Manifest
Section titled “Manifest”{ "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.
Small JSON data sources
Section titled “Small JSON data sources”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
Section titled “Localized text”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 (PortugueseMarco,Terca-feira, SpanishMiercoles, FrenchFevrier, GermanMaerz), and the abbreviations come from tables, not from cutting the name (FrenchJuinandJuil,Sept; PortugueseSet; GermanSon,Mon). A"spec": 1theme keeps English names, whatever the device language.- A text layer’s
valuemay 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 in the package
Section titled “Fonts in the package”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:
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.
Scrolling text
Section titled “Scrolling text”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.
Arcs, hands and tick marks (spec 2)
Section titled “Arcs, hands and tick marks (spec 2)”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).
arcdraws a ring segment about the centerx,y: the outer edge isradiusand the band isstrokeWidthwide inside it, like a circle’s stroke. It starts atstart(−360 to 360) and runssweepdegrees (−360 to 360) clockwise, or counter-clockwise whensweepis negative. Asweepof 360 or more is a full ring and asweepof 0 draws nothing, so a progress ring at 0 % is simply empty. AstrokeWidthof at leastradiusfills the wedge. The ends are flat and radial.radiusis 1–240.handdraws a line through the pivotx,y, fromtail(0–240, default 0) behind it tolength(1–240) in front, in directionangle(−360 to 360), with a centered stroke and round ends likeline.ticksdrawscount(1–60) evenly spaced radial marks around the centerx,y, the first at 12 o’clock. Each runslength(1–240, stopped at the center) inward fromradius(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.
Animation
Section titled “Animation”{"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.
Dynamic bindings
Section titled “Dynamic bindings”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.
Numeric mapping
Section titled “Numeric mapping”{"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 |
Color stops
Section titled “Color stops”{"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.
Property matrix
Section titled “Property matrix”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.
Built-in sources
Section titled “Built-in sources”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: <source>.age
Section titled “Data age: <source>.age”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
agekeeps that field; no built-in age is added to that source. - In a
"spec": 1themew.ageis refused like any name that is not a declared field.
Example: a dynamic dashboard
Section titled “Example: a dynamic dashboard”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— aloop-scrolling{status.label}text viewport;headline-bounce— a second,bounce-scrolling label, so both scroll modes are copyable side by side;level-trackandlevel-bar— a rounded track and a bar whosewidthmaps fetched level 0–100 to 0–200 px, withfillselected from green/amber/red color stops at the same thresholds;level-caption— status text whosecoloris bound from the same numericstatus.levelfield, 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.
Limits and storage
Section titled “Limits and storage”| 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.
Device package format
Section titled “Device package format”.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.
HTTP API
Section titled “HTTP API”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.
Development checks
Section titled “Development checks”After resolving PlatformIO’s dependencies:
sh tests/theme/run.shpython3 -m unittest discover -s tests/theme -p 'test_*.py'node tests/theme/test_webui.jspio run -e smalltv -e smalltv_c2 -e smalltv_esp32 -e smalltv_esp32_wg -e smalltv_esp32_8mb -e smalltv_leanThe 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:
node tests/theme/test_preview_browser.mjs# Set CHROME=/path/to/chromium when the browser is not named google-chrome.