Skip to content

Testing without hardware

Everything in mixlar.emulator exists so you can develop and test a plugin without an M1X plugged in. It’s a headless stand-in for the device side of the serial link — it plays both directions of the wire protocol, so you can drive a plugin with fake button presses and assert on what it pushes back, all inside a normal test runner.

MockDevice is a stdlib-only class that loads a widget spec, tracks the live data table, and can simulate both directions of traffic:

  • feed() plays the PC → device role, so it can be wired up as a plugin’s data sender.
  • press / hold / toggle / slide / switch_page play the device → PC role, emitting the same WEVENT/WPAGE lines the firmware would and routing them to a bound plugin exactly like custom_widget.ino does.
from mixlar.emulator import MockDevice
device = MockDevice()
device.attach() # installs itself as PluginRegistry's sender
device.bind_plugin(my_plugin) # routes events to my_plugin.on_widget_event, etc.
device.load_widget("widgets/pomodoro/widget.json")
device.press("start_pause") # simulates a tap -> WEVENT,...,press
device.toggle("power", on=True) # -> WEVENT,...,on
device.slide("brightness", 80) # -> WEVENT,...,value=80
device.switch_page("Controls") # -> WEVENT,...,_page,Controls
device.data("pomodoro.remaining") # read back a value the plugin pushed
device.snapshot() # the whole live data table, as a dict
device.render_to("preview.png") # needs Pillow (the [render] extra)
device.detach()

attach() sets the process-wide PluginRegistry’s _wdata_sender/_wimg_sender to the device’s own feed/_receive_image methods. This is the exact seam push_widget_data/push_widget_image call through, so once attached, any plugin instance’s pushes land in this device — whether or not you explicitly bound it. detach() restores both to None, and is idempotent: it only clears them if they still point at this device.

bind_plugin(plugin) additionally routes simulated device→PC traffic (press/hold/toggle/slide) to plugin.on_widget_event, and fires on_widget_shown/on_widget_hidden around load_widget calls. It also adopts plugin.widget_id as the device’s active widget id if none is set yet, mirroring how the app selects a plugin’s bundled widget via CUSTOMWIDGET,<id> on connect.

load_widget() accepts a path or an already-parsed dict. It scans every element for bind keys (so the device knows what the widget actually subscribes to) and interactive ids on btn/toggle/slider elements (what press/toggle/slide accept). For multi-page specs it seeds the current page to the first non-hidden page, matching the device’s own startup behavior. See Widget spec for the full element/page format.

feed(line) — acting as the PC→device sender

Section titled “feed(line) — acting as the PC→device sender”

feed is what gets installed as the registry’s data sender. It parses one or more newline-joined protocol lines and applies them:

Verb Effect
WDATA,<key>,<value> Updates the data table. Rejects a new key past the 32-key budget with a logged WDATA_ERR, exactly like the firmware.
WPAGE,<name> Switches the current page.
CUSTOMWIDGET,<id> Updates the tracked widget id.

Feed it manually to simulate the PC side without a plugin at all — handy for testing a hand-written widget’s bind/show wiring in isolation:

device.feed("WDATA,pomodoro.pct,40")
device.feed("WDATA,pomodoro.status,Work - Running")

Because device.data() and device.snapshot() read back whatever the plugin pushed, a plain pytest function is enough to cover a plugin’s event handling end to end:

from mixlar.emulator import MockDevice
from my_plugin import PomodoroPlugin
def test_start_pause_toggles_running_state():
plugin = PomodoroPlugin()
device = MockDevice()
device.attach()
device.bind_plugin(plugin)
device.load_widget("widgets/pomodoro/widget.json")
device.press("start_pause")
assert device.data("pomodoro.status") == "Work - Running"
device.detach()

render_widget/save_preview draw a widget spec’s current state to a PNG so you can eyeball layout, colors, and live bindings while authoring, without hardware:

from mixlar.emulator.render import render_widget, save_preview
img = render_widget("widgets/pomodoro/widget.json", data={"pomodoro.pct": "40"})
save_preview(spec_dict, "preview.png", data=device.snapshot())

This needs Pillow (pip install "mixlar-sdk[render]") — everything else in the emulator works without it. It’s a best-effort approximation of what custom_widget.ino draws, not pixel-perfect.

Supported element types:

Type Rendered as
label Text, with fmt/bind templating and alignment
bar A filled rounded-rect, horizontal or vertical depending on aspect
arc An arc gauge, with its centered fmt label
panel A flat rounded-rect background
led A filled circle, on/off color from bind
line A flat rectangle
img The real asset, loaded relative to the spec’s folder
qr A placeholder box, not an actual scannable code
btn / toggle / slider Styled boxes approximating the on-device control

show (layer visibility) and bind/fmt templating are both honored using whatever data dict you pass. Unknown element types are skipped silently — the same forward-compatible behavior as the firmware.

The CLI wraps all of the above so you can poke at a plugin without writing a script:

Terminal window
mixlar-sdk emulate # load, print state, exit
mixlar-sdk emulate --widget other_widget # pick a non-default declared widget
mixlar-sdk emulate --render preview.png # render a PNG
mixlar-sdk emulate --data pct=40 --data status="Work" --render preview.png
mixlar-sdk emulate --interactive # drop into a REPL

It loads the package’s plugin instance(s), attaches a MockDevice, binds the first MixlarPlugin subclass found, loads the widget named by --widget (or the manifest’s first declared widget), applies any --data k=v seeds, prints the data table and interactive-element list, then optionally renders and/or starts the REPL. See mixlar-sdk CLI for the rest of the command surface, and Validate & emulate for how this fits alongside spec validation.

mixlar> press start_pause
[ok] log: <- WEVENT,pomodoro,start_pause,press
mixlar> data pomodoro.status Working
[ok] log: -> WDATA,pomodoro.status,Working
mixlar> toggle power on
mixlar> slide brightness 75
mixlar> page Controls
mixlar> snapshot
mixlar> quit
  1. press <id> / hold <id> — simulate a tap or long-press on a btn.

  2. toggle <id> on|off — simulate flipping a toggle.

  3. slide <id> <n> — simulate dragging a slider to value n.

  4. page <name> — switch the active page.

  5. data <key> <val> — feed a WDATA line directly, as if the plugin pushed it.

  6. snapshot — print the current data table. help lists all commands; quit exits.

  • Plugin API — the on_widget_event/on_widget_shown/on_widget_hidden hooks the emulator drives.
  • Widget spec — the element and page format load_widget() parses.
  • Convert a plugin — a worked example that leans on the emulator while porting an existing integration.