Building widgets in Python
A device widget is data, not code: a widget.json (spec mixw:1) plus optional PNG/txt assets, rendered by the firmware onto the M1X’s 240×140 widget panel. This page covers the SDK tooling built around that spec — the Widget builder, the linter, and how a plugin pushes live data into a running widget.
Widget — the builder
Section titled “Widget — the builder”Hand-writing widget JSON is error-prone — typo a field name, forget a required id, ship a bad hex color — and those mistakes only surface as a silently-wrong render on a real device. Widget builds the same spec through a fluent, typed API instead.
from mixlar.widgets import Widget
w = (Widget("PC Stats", bg="16171B") .label(10, 6, text="CPU", w=120, s=14, c="8E8E93") .label(10, 22, bind="cpu_load", fmt="{v}%", w=120, s=38, c="FFFFFF") .bar(10, 72, bind="cpu_load", min=0, max=100, w=130, h=8, c="1DB954", bgc="2C2C2E") .arc(156, 12, bind="mem_load", min=0, max=100, d=86, w=8, c="FF9F0A", bgc="2C2C2E", fmt="{v}%", ls=18) .button("ping", 156, 104, text="Ping", w=64, h=26))
w.save("widgets/pc_stats") # writes widgets/pc_stats/widget.jsonEvery element method appends and returns self, so calls chain. Only fields you actually pass end up in the JSON — the builder never emits nulls or defaults, matching a hand-tuned spec.
Element methods
Section titled “Element methods”| Method | Firmware type (t) |
Required positional args |
|---|---|---|
.label() |
label |
x, y |
.bar() |
bar |
x, y |
.arc() |
arc |
x, y |
.img() |
img |
x, y |
.panel() |
panel |
x, y, w, h |
.led() |
led |
x, y |
.line() |
line |
x, y |
.qr() |
qr |
x, y |
.button() |
btn |
id, x, y |
.slider() |
slider |
id, x, y |
.toggle() |
toggle |
id, x, y |
button, slider, and toggle take a required id as their first positional argument (the WEVENT id) — the other builders don’t, since they’re not interactive.
w = Widget("Dash")w.page("Dash").label(10, 10, text="Home")w.page("Controls").button("go", 10, 10, text="Go")Without any .page() call, elements accumulate into a flat top-level "elements" list. The first .page() call switches the widget into multi-page mode (top-level "pages"); every element call after that targets the most-recently-started page.
Menu theming
Section titled “Menu theming”w.menu(c="26263A", selc="9A9AFF", tc="FFFFFF", bgc="000000", o=190)Sets the optional top-level "menu" block that restyles the built-in tap-to-switch-pages overlay. See WIDGETS.md’s Menu theming section for every field, and Widget spec for the page-switching protocol.
Output
Section titled “Output”| Method | Does |
|---|---|
w.to_dict() |
The spec as a plain dict |
w.to_json() |
The spec as a JSON string |
w.save(path) |
Writes widget.json — path may be a directory (filename appended) or a full file path |
w.lint(types=None) |
Runs the linter against the built spec, returns (errors, warnings) |
mixlar.widgets.load(path) |
Reads an existing widget.json (path or directory) back into a dict, for inspecting hand-written or third-party widgets |
The linter — mixlar.validator
Section titled “The linter — mixlar.validator”from mixlar import validator
errors, warnings = validator.lint_widget("widgets/pc_stats/widget.json")lint_widget(source, types=None) accepts a path or an already-loaded dict. It checks:
"mixw"version- Unknown top-level keys
- Missing or unknown element
"t" - Unknown fields per element
- Non-6-hex colors
- Geometry that overruns the 240×140 panel
- Missing
idon interactive elements (btn/toggle/slider— without one, noWEVENTcan ever fire for it) - The 48-element budget
Where the valid element types come from
Section titled “Where the valid element types come from”The set of valid element types comes from the actual firmware source when it’s reachable: validator.firmware_types() walks up from the current directory looking for Firmware/firmware_demo/custom_widget.ino and extracts every strcmp(t, "...") it finds — the same list the device really accepts, kept honest by reading it out of the firmware rather than hand-copying it.
When that source isn’t available (a third-party author without a repo checkout), it falls back to validator.FALLBACK_TYPES, a pinned copy of the same eleven types:
label, bar, arc, img, panel, led, line, qr, btn, slider, toggleLinting a whole package
Section titled “Linting a whole package”from mixlar import validator
report = validator.lint_package("dist/my-plugin")lint_package(pkg_dir, app_version=None) runs the full manifest validation (from mixlar.manifest) plus widget-body linting for every widget the manifest declares, folded into one ValidationReport with messages prefixed widget '<id>': . This is what mixlar-sdk validate and mixlar-sdk dev call under the hood — see Validate & emulate.
Worked example: build and lint
Section titled “Worked example: build and lint”-
Build the spec with the fluent API.
from mixlar.widgets import Widgetfrom mixlar import validatorw = (Widget("PC Stats", bg="16171B").label(10, 6, text="CPU", w=120, s=14, c="8E8E93").label(10, 22, bind="cpu_load", fmt="{v}%", w=120, s=38, c="FFFFFF").bar(10, 72, bind="cpu_load", min=0, max=100, w=130, h=8,c="1DB954", bgc="2C2C2E").button("ping", 156, 104, text="Ping", w=64, h=26)) -
Lint before writing anything to disk.
errors, warnings = w.lint()if errors:raise SystemExit(f"widget has {len(errors)} error(s): {errors}")for msg in warnings:print("warning:", msg) -
Save once it’s clean.
path = w.save("widgets/pc_stats")print("wrote", path)
push_widget_data — feeding a widget
Section titled “push_widget_data — feeding a widget”From inside a plugin (see Plugin API for the full hook list):
self.push_widget_data("cpu_load", 42) # → WDATA,<widget_id>.cpu_load,42self.push_widget_data("status", "Idle…")- Namespaced automatically by
self.widget_idunless the key starts with_(reserved for shared/global feeds like_time). - Thread-safe; returns
Falsewith no host sender attached (no device, or off-app with nothing wired up — see Emulator to attach aMockDeviceas the sender for testing). - Keys are capped at 47 characters and commas/newlines stripped, matching the firmware’s on-device limits exactly (
mixlar.protocol.VALUE_MAX_LEN).
push_widget_image(key, source, w, h, widget=None) streams a resized, RGB565-packed image to an img element’s binding. It blocks on the transfer — call it from a worker thread, never the GUI thread. Off-app it goes through Pillow (mixlar.imaging); inside the app the native fast path is used.
The protocol at a glance
Section titled “The protocol at a glance”mixlar.protocol mirrors the widget spec as constants, line builders, and parsers — the emulator, the linter, and tests use it directly; a plugin never touches the serial port itself. Full detail lives in Widget protocol; the shape:
| Verb | Direction | Line | Via |
|---|---|---|---|
CUSTOMWIDGET |
PC to device | CUSTOMWIDGET,<id> |
Load and switch the panel |
WDATA |
PC to device | WDATA,<key>,<value> |
protocol.wdata() / push_widget_data |
WPAGE |
PC to device | WPAGE,<name> |
protocol.wpage() — force the active page |
WIDGET_UPLOAD |
PC to device | WIDGET_UPLOAD,<id>/<file>,<size> + bytes |
protocol.widget_upload() |
WEVENT |
Device to PC | WEVENT,<widget>,<element>,<action> |
protocol.parse_event() to Event(widget, element, action, value) |
WMACRO |
Device to PC | WMACRO,<name> |
protocol.parse_macro() — button/toggle "macro" field, no plugin needed |
WLIST |
Device to PC | WLIST,<id>,<version> |
protocol.parse_wlist() — installed-widget inventory |
Device-side limits enforced on both ends, as constants: CANVAS_W/CANVAS_H (240×140), MAX_ELEMENTS (48), MAX_PAGES (6), MAX_DATA_KEYS (32), KEY_MAX_LEN (23), VALUE_MAX_LEN (47), SPEC_MAX_BYTES (16 KB).
WEVENT actions: ACTION_PRESS/ACTION_HOLD (buttons), ACTION_ON/ACTION_OFF (toggles), a value=<n> form for sliders (parsed into Event.value, action normalized to "value"), and ACTION_PAGE ("_page") for page-switch announcements where action carries the new page’s name.
Next steps
Section titled “Next steps”- Read the full field-by-field element and menu reference in Elements reference.
- Wire up live data and events end to end in Data & events.
- See a complete widget + plugin pairing (arc countdown, status label, Start/Pause and Reset buttons) in the Pomodoro guide.
- Validate and preview a widget before shipping with the CLI.