ZeroTrace Desktop
Writing Apps
The four globals, the run/re-run lifecycle, persistence, utils, console behavior, timers, and events
An app is written in JavaScript. Your code has four things always available to it, and you combine them to build the panel and drive the device:
| Global | What it's for |
|---|---|
ui | The panel builder — ui.button(...), ui.slider(...), and so on. See UI controls. |
device | The connected device — call an operation, read the result, react to events. See Device API. |
console | Printing — console.log(...) and console.error(...) write to the Console pane. |
utils | Small helpers for common needs, plus a per-app persistent store. |
This is deliberately not a locked-down sandbox — it's your own code driving your own device on your own machine. Top-level await works, so you can write straight-line async code without wrapping it in an IIFE.
The run / re-run lifecycle
Every press of Run re-executes your whole script from the top and re-declares the whole panel. To keep that from piling things up:
- Timers are cleared first. Anything started with
ui.every,ui.after, or a plainsetInterval/setTimeoutfrom the previous run is stopped before your code runs again — a monitor loop can never stack up copies of itself. - Device event subscriptions are cleared too. Every
device.on(...)listener from the previous run is dropped before the new run starts. - User-edited values survive. A slider, input, toggle, select, number, color, swatches, or tabs control keeps the value you last set it to, matched up by its kind and label — so re-running to apply a code change doesn't reset the panel out from under you. Script-driven outputs (readout, stat, progress, chart, code) do not persist this way; your code re-populates them each run.
- A handle only lives for its own run. A handle returned by
ui.button(...)this run is unrelated to the one from last run — don't hold a stale handle across a Run in, say, a module-level variable outside your script's scope; there isn't one, each run is a fresh top-to-bottom execution.
// Runs top to bottom every time you press Run.
ui.title("Counter demo")
const label = ui.readout({ label: "Count", value: "0" })
let n = 0
ui.button({
label: "Increment",
onClick: () => { n += 1; label.value = String(n) },
})
// Cleared automatically the next time you press Run — no accumulation.
ui.every(1000, () => console.log("tick"))
Reacting to the user and the device
Give an input an onChange (or a button an onClick) and your code runs when the user touches that control. Inside it you can read other controls, drive the device, and update outputs, and the panel reflects the change immediately.
onChange fires only when the user edits the control — moves a slider, types in a field, flips a toggle. Setting .value from your own code does not re-fire it, so a handler that writes back to its own control can't loop forever.
const brightness = ui.slider({
label: "Brightness",
max: 255,
onChange: (v) => device.rpc("led.set", { brightness: v }),
})
device.state.on('change', cb) is the guess most people make, but subscribing always lives on the root: device.on('change', cb). Calling .on(...) anywhere else in the chain is treated as an RPC named "...on" and fails — the runtime detects this specific shape and throws a clear error instead of failing silently.
Timers
Runs a callback on a repeating interval. Returns a handle with .stop() if you want to cancel it early; otherwise it's stopped for you automatically on the next Run.
const heap = ui.chart({ label: "Free heap", unit: "KiB" })
ui.every(1000, async () => {
const s = await device.rpc("state.read")
heap.push(Math.round((s.free_heap ?? 0) / 1024))
})
Accepts arguments as either (ms, fn) or (fn, ms) — both work, since mixing them up from memory is an easy mistake to make.
If a callback passed to ui.every (or a managed setInterval) throws several times in a row, the runtime stops that timer on its own and reports it once, rather than flooding the Console with the same error forever. Fix the cause and press Run again.
Console behavior
console.log(...) and console.error(...) write to the Console pane. A few things worth knowing:
- Repeats collapse. The same line logged over and over in a row is shown once with a
×Ncount, instead of scrolling everything else away — useful since a tight timer loop can otherwise flood the pane in seconds. - Errors from anywhere are caught and reported, not just ones thrown during the initial run. A throw inside a button's
onClick, a control'sonChange, aui.everytimer, or adevice.oncallback is caught and printed to the Console rather than disappearing as a silent, unhandled rejection — including when the callback isasyncand rejects. console.errormarks a line as an error in the pane (distinct styling fromconsole.log), which is the conventional way to surface a failure without throwing and stopping the rest of your script.
ui.button({
label: "Read state",
onClick: async () => {
try {
const s = await device.rpc("state.read")
console.log("op:", s.op, "battery:", s.battery_pct + "%")
} catch (e) {
console.error("state.read failed:", e.message || e)
}
},
})
The utils namespace
| Function | Signature | Does |
|---|---|---|
utils.sleep | sleep(ms) => Promise<void> | Resolves after ms milliseconds — await utils.sleep(2000) inside an async handler. |
utils.hex | hex(n) => string | Clamps n to 0–255, rounds it, and returns a 2-digit lowercase hex string ("00"–"ff"). |
utils.hexToRgb | hexToRgb(hex) => [number, number, number] | Parses a "#rrggbb" (or "rrggbb") string into an [r, g, b] triple; an unparseable channel reads as 0. |
ui.title("Color demo")
const picker = ui.color({ label: "Pick a color", value: "#4f46e5" })
const out = ui.readout({ label: "As RGB" })
ui.button({
label: "Apply",
onClick: async () => {
const [r, g, b] = utils.hexToRgb(picker.value)
out.value = `${r}, ${g}, ${b}`
await device.rpc("led.set", { r, g, b })
await utils.sleep(50)
},
})
Remembering data between runs (store)
Alongside ui/device/console/utils, your script also has a store — a small per-app key-value store that survives between runs and between sessions, so an app can remember a setting or a saved list the next time you open it. Values are JSON, so objects and arrays work, not just strings.
| Method | Does |
|---|---|
store.get(key, fallback?) | Read a value. Returns fallback (default undefined) if the key was never set. |
store.set(key, value) | Write a value. |
store.delete(key) | Remove a key. |
store.has(key) | Whether the key currently has a value. |
store.keys() | Every key this app has stored. |
store.clear() | Wipe everything this app has stored. |
ui.title("Remembers your last target")
const target = ui.input({
label: "Target MAC",
value: store.get("target", ""),
onChange: (v) => store.set("target", v),
})
This lives on your machine alongside the rest of your saved apps — it isn't synced anywhere and isn't part of what gets published if you share the app on the Hub. See Publishing for what does travel with an app.