ToyTool Firmware

SmallTV-Ultra API: push widgets from your scripts

Send numbers, lists, notes and notifications to a GeekMagic SmallTV-Ultra from any script over your home network, with curl, Python and Node examples.

8 min read

The ToyTool mascot, a SmallTV-Ultra, showing computer stats from the widgets API

With ToyTool Firmware, a GeekMagic SmallTV-Ultra takes small JSON messages over your home network and turns them into pages on the screen: a big number with a progress bar, a list, a note, or a notification on top of everything. One HTTP request from curl, a Python or Node script, a phone shortcut, anything that can send a POST. The request goes straight to the screen: no cloud and no API key in between.

Four custom widgets on the 240×240 screen: “Course sales 37 of 50” with a progress bar, a “Computer” list with CPU, memory and disk, “my-project 842 stars” with a goal bar, and a text reminder
The four widgets from this guide, drawn by the firmware’s own rendering code (sample values). The green is the accent colour from the screen settings.

Everything here is checked against the source of ToyTool Firmware 1.16.0. The endpoints belong to ToyTool: the stock GeekMagic firmware has a different web interface (Home Assistant’s geekmagic-hacs integration uses it to upload pictures — see the firmware options compared).

What you need

  • A SmallTV-Ultra running ToyTool and activated in your account (the Basic licence is $3.99 per screen, paid once).
  • The screen’s IP address. The screen shows it for a moment as “Connected 192.168.…” right after it joins Wi‑Fi, and the screen’s card in your ToyTool account shows it when Local network address is on in the screen settings. A DHCP reservation in your router keeps the address from changing.
  • A computer, server or phone on the same network.

The examples use 192.168.1.50. Put your screen’s address in its place.

Your first widget

curl -X POST http://192.168.1.50/api/widget \
  -H "Content-Type: application/json" \
  -d '{"id": "sales", "title": "Course sales", "value": "37", "unit": "of 50",
       "sub": "seats sold this month", "progress": 0.74, "show": true}'

The screen answers {"ok":true} and, because of "show":true, jumps straight to the new page. Without it the widget waits for its turn in the rotation, as part of Widgets — one of the screen’s standard pages, ten seconds per widget. If your widgets never come round, add Widgets to the screen’s rotation in your account: it’s with the standard screens in the Data collection.

On Windows, PowerShell’s own command is easier than juggling quotes for curl.exe:

$body = @{
  id = "sales"; title = "Course sales"; value = "37"; unit = "of 50"; progress = 0.74
} | ConvertTo-Json
Invoke-RestMethod -Method Post -Uri "http://192.168.1.50/api/widget" `
  -ContentType "application/json" -Body $body

How widgets work

  • A screen holds up to four widgets. Each one is a page of its own.
  • The id names a widget. Send the same id again and you update it; fields you leave out keep their old values.
  • A fifth id is refused with 400 {"ok":false,"error":"need id; max 4 widgets"} — delete one first.
  • The Claude Code helper (Claude Code usage on your desk) also uses one of the four places, under the id cc.

There are three kinds, picked with type (metric when you leave it out on the first send):

typeWhat the page showsMain fields
metrica big number with a unit, a caption of up to two lines, an optional progress barvalue, unit, sub, progress
listup to five label–value rows; the first value is in the accent colourrows
texta block of texttext

Every field

Text limits are in bytes of UTF‑8 (a Latin letter is one byte, a Cyrillic one is two). Longer text is cut.

FieldWhat it doesLimit
idRequired. Names the widget; the same id updates it15 bytes
typemetric, list or text—
titleThe small heading at the top31 bytes
valueThe big number of a metric (a string: "37", "1.4M")23 bytes
unitNext to the value: "of 50", "%"11 bytes
subThe caption under the value79 bytes
progressA number from 0 to 1: the bar at the bottom. null removes the bar—
rows[["label","value"], …] for a list5 rows, 23 bytes per cell
textThe body of a text widget159 bytes
color"#RRGGBB": this widget’s own accent (bar, first row)—
imageA picture behind the widget, fetched by the screen: a plain http:// URL of a baseline JPEG under 120 KB, best at 240×240. "" removes it—
stateA short word or number for watch faces ({w_<id>_st}, see below)11 bytes
ttlSeconds until the widget disappears by itself—
showtrue: show this widget now—
pagefalse: no page of its own — the data is only for watch faces—

Fresh numbers: send a ttl

A widget sent with ttl disappears when the time runs out, and the screen never writes it to its flash memory. A widget without ttl is saved and comes back after a restart; it stays until you delete it or send something new.

So a script that updates every 30 seconds should send a ttl a few times longer than that, say 120. If the script stops, the page goes away instead of showing yesterday’s numbers — and the flash chip isn’t rewritten every half minute. Each update without ttl makes the widget permanent again, so send it every time.

Python: your computer’s load as a list

The psutil package reads CPU, memory and disk on Windows, macOS and Linux (pip install psutil). The rest is the standard library.

import json, time, urllib.request
import psutil

SCREEN = "http://192.168.1.50"

def send(widget):
    req = urllib.request.Request(f"{SCREEN}/api/widget", method="POST",
                                 data=json.dumps(widget).encode(),
                                 headers={"Content-Type": "application/json"})
    urllib.request.urlopen(req, timeout=5).read()

while True:
    rows = [["CPU", f"{psutil.cpu_percent(interval=1):.0f}%"],
            ["Memory", f"{psutil.virtual_memory().percent:.0f}%"],
            ["Disk", f"{psutil.disk_usage('/').percent:.0f}%"]]
    try:
        send({"id": "pc", "type": "list", "title": "Computer", "rows": rows, "ttl": 120})
    except OSError as e:          # the screen is off or out of reach: try again next time
        print("screen:", e)
    time.sleep(30)

Node: a GitHub star counter with a goal

Node 18 or newer, no packages: fetch is built in. It asks GitHub’s public API for a repository’s stars every ten minutes (unauthenticated requests are limited to 60 an hour, so this stays far below) and shows them with a bar toward a goal.

// stars.mjs — run: node stars.mjs 192.168.1.50 owner/repo
const [screen, repo] = process.argv.slice(2);
const GOAL = 1000;

async function update() {
  try {
    const r = await fetch(`https://api.github.com/repos/${repo}`, {
      headers: { 'User-Agent': 'smalltv-widget' },
    });
    if (!r.ok) throw new Error(`GitHub ${r.status}`);
    const stars = (await r.json()).stargazers_count;
    const widget = {
      id: 'stars',
      title: repo.split('/')[1].slice(0, 31),
      value: String(stars),
      unit: 'stars',
      sub: `goal: ${GOAL.toLocaleString('en-US')}`,
      progress: Math.min(1, stars / GOAL),
      ttl: 1800,
    };
    const s = await fetch(`http://${screen}/api/widget`, {
      method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(widget),
    });
    if (!s.ok) throw new Error(`screen ${s.status}`);
  } catch (e) {
    console.error(new Date().toLocaleTimeString(), e.message);
  }
}

update();
setInterval(update, 10 * 60 * 1000);

Notes and notifications

A note stays in the rotation like any widget:

curl -X POST http://192.168.1.50/api/widget \
  -H "Content-Type: application/json" \
  -d '{"id": "note", "type": "text", "title": "Reminder",
       "text": "Team call at 16:00. Bring the Q4 numbers", "ttl": 3600}'

A notification is different: it covers whatever is on the screen for a few seconds and then lets it be.

curl -X POST http://192.168.1.50/api/notify \
  -H "Content-Type: application/json" \
  -d '{"title": "CI", "text": "Build #418 passed", "sec": 15, "color": "#4ADE80"}'

text is required (up to 159 bytes), title is optional (31 bytes), sec is how long it stays — 2 to 600 seconds, 10 when you leave it out — and color paints the dot next to the title. For a shortcut or a quick test there’s a GET form too:

curl "http://192.168.1.50/api/notify?title=CI&text=Build%20passed&sec=15"
Left: a notification with a green dot, “CI” and “Build #418 passed”. Right: a watch face with a ring that shows CPU 23% and RAM 41% from a widget
Left: the notification from the example above. Right: a watch face that reads the “pc” widget’s numbers (sample values).

Manage widgets

curl http://192.168.1.50/api/widgets                      # list them
curl -X DELETE "http://192.168.1.50/api/widget?id=note"    # delete one

The list gives every widget’s id, type, title, its value or text, and "temporary": true for the ones sent with a ttl. Deleting an id that isn’t there answers 404 {"ok":false,"error":"no such widget"}.

You can also take a picture of the screen: curl -o screen.bmp http://192.168.1.50/api/screen.bmp saves a 240×240 24‑bit BMP of what’s on it right now.

Put your numbers into a watch face

A widget sent with "page": false gets no page of its own: its data only feeds watch faces. Any face can read it through variables built from the widget’s id:

In a faceGives
{w_pc} · {w_pc_u}the value and the unit
{w_pc_t} · {w_pc_s} · {w_pc_st}the title, the caption (sub) and the state
{w_pc_0} … {w_pc_4}the value of row 0…4; {w_pc_0l} … {w_pc_4l} its label
"v": "w_pc" in a ring, bar or gaugethe widget’s progress (0–1)
"v": "w_pc_0"the number at the start of row 0’s value, read as a percent: "23%" → 0.23

Use only letters and digits in an id you want in a face — the variable name is split at the first underscore, so a widget called cpu_load can’t be read as {w_cpu_load}. The face on the right above is just four elements:

{
  "id": "cpu",
  "name": "CPU",
  "bg": { "color": "#000000" },
  "el": [
    { "t": "ring", "x": 120, "y": 120, "r": 96, "width": 12, "v": "w_pc_0", "color": "accent", "trackAlpha": 50 },
    { "t": "text", "x": 120, "y": 100, "font": "small", "align": "c", "text": "{w_pc_0l}", "color": "dim" },
    { "t": "text", "x": 120, "y": 150, "font": "big", "align": "c", "text": "{w_pc_0}" },
    { "t": "text", "x": 120, "y": 178, "font": "small", "align": "c", "text": "RAM {w_pc_1}", "color": "dim" }
  ]
}

How to design one is in Build your own watch face, and every element and variable is in the face format reference. Until the data arrives, those spots stay empty and the rest of the face keeps working.

Home Assistant and other tools

Anything that can send an HTTP POST with a JSON body can drive these widgets: Home Assistant’s RESTful Command, Node‑RED, n8n, a cron job, an iOS or Android shortcut. The request is the same as in the curl examples; ready recipes are in Home Assistant sensors on the SmallTV-Ultra (a rest_command, automations and a face) and send messages from your phone (iOS Shortcuts, Android).

Security: it’s your network’s API

The screen’s API has no password. Anyone and anything on your home network can send widgets and notifications, change settings, restart the screen or upload firmware to it — and because the screen allows cross‑origin requests, so can a web page open in a browser on that network. Keys don’t leak through it: GET /api/config shows the YouTube key and the Notion token only by their last four characters and never shows the Wi‑Fi password. Still:

  • don’t forward the screen’s port 80 from your router to the internet;
  • if guests share your network, a separate guest Wi‑Fi keeps them off it.

Limits

  • Four widgets per screen, shared with the Claude Code helper.
  • Pictures only over plain http:// and only as baseline JPEG under 120 KB: the ESP8266 doesn’t have the memory for TLS on top of decoding a picture.
  • Text is drawn with the screen’s own fonts. Emoji and unusual symbols aren’t in them.
  • The screen has to be on the same network as whatever sends the request; there’s no cloud relay for widgets.
  • ttl counts from the moment the widget arrives and doesn’t survive a restart.

Sources: the firmware’s source code (src/web/web.cpp, src/main.cpp); the pictures are renders of those exact requests by the firmware’s drawing code.