Skip to content

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.

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.json

Every 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.

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.

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.

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.jsonpath 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
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 id on interactive elements (btn/toggle/slider — without one, no WEVENT can ever fire for it)
  • The 48-element budget

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, toggle
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.

  1. Build the spec with the fluent API.

    from mixlar.widgets import Widget
    from mixlar import validator
    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")
    .button("ping", 156, 104, text="Ping", w=64, h=26))
  2. 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)
  3. Save once it’s clean.

    path = w.save("widgets/pc_stats")
    print("wrote", path)

From inside a plugin (see Plugin API for the full hook list):

self.push_widget_data("cpu_load", 42) # → WDATA,<widget_id>.cpu_load,42
self.push_widget_data("status", "Idle…")
  • Namespaced automatically by self.widget_id unless the key starts with _ (reserved for shared/global feeds like _time).
  • Thread-safe; returns False with no host sender attached (no device, or off-app with nothing wired up — see Emulator to attach a MockDevice as 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.

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=&lt;n&gt; 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.

  • 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.