Skip to content

Building a tool with busylib

The setup wizard that ships with the library is a small, real program: it reads what a bar is currently doing, decides what's left to configure, and changes only that. This page walks through how it's put together, because the same shape works for most tools you'd write against a bar.

The code shown here isn't a transcription — it's pulled straight from examples/setup/operations.py, so it can't drift from the program that runs.

One function per device operation

Every interaction with the bar is its own function that takes a client and returns plain data. No printing, no prompting, no program state:

read_device_name async

read_device_name(client: AsyncBusyBar) -> str

Read the device's current name.

rename_device async

rename_device(client: AsyncBusyBar, name: str) -> None

Rename the device. The name appears on the bar and in discovery.

That separation is what makes them reusable. read_device_name is equally at home in a monitoring script, and it's testable without a terminal.

Reading state before changing it

Most operations come in pairs: read what's there, then act only if needed. Reading is where the device's quirks live, so that's where the comments go:

read_firmware_state async

read_firmware_state(client: AsyncBusyBar) -> FirmwareState

Read the device's API version and the firmware version behind it.

/api/version only reports api_semver; the firmware's own version string lives under /api/status. Reading the second is best-effort, so an unreachable status endpoint costs the label rather than the verdict.

Two things worth copying from this. It reads from two endpoints because neither has the whole answer, and it treats the second as best-effort — an unreachable /api/status costs the version label, not the verdict. And it asks the library whether the device is supported rather than comparing version strings itself.

Operations that need to wait

Some device actions are asynchronous: you ask, then poll for the outcome.

find_available_update async

find_available_update(client: AsyncBusyBar, *, timeout: float = UPDATE_TIMEOUT_SECONDS, poll_interval: float = UPDATE_POLL_INTERVAL_SECONDS) -> str | None

Ask the device to check for an update and wait for the verdict.

Returns the offered version, or None when the check finishes with nothing to install. Raises TimeoutError if the device never reports a verdict - that is not the same as "no update", and saying so would repeat the mistake this function exists to avoid.

The device keeps the previous check's outcome until a new one lands, and the check it performs on request is asynchronous, so the state has to be read carefully in both directions. A stale available_version must not be installed - the device rejects that with 400 "Update not available" - and a stale not_available must not be reported as the answer, which would hide an update the bar is actually offering. So the state from before the request is recorded, and a verdict is only accepted once the device has moved on from it or has visibly started checking - falling back to the reported state after a few polls, so a firmware that never exposes the transition doesn't stall the whole timeout.

The subtlety here cost a release fix: available_version keeps the result of an earlier check, so acting on it while a new check is running gets a 400 "Update not available" back. Waiting for the device's own status to read available is what makes the install stick.

Operations that are allowed to fail

Not every failure deserves to stop the program:

scan_networks async

scan_networks(client: AsyncBusyBar) -> list[types.Network]

Scan for nearby networks, returning an empty list if scanning is refused.

The device cannot scan while associated - it answers 400 "Scan not possible when connected" - so callers fall back to entering an SSID.

A bar refuses to scan while it's associated. That's not an error worth aborting over — the caller can just ask for an SSID instead — so the function returns an empty list and says why in the log.

Assembling the pipeline

With the operations in place, a step is only the conversation: what to show, what to ask, and when the work is already done.

class NameStep(SetupStep):
    key = "name"
    title = "Device name"

    async def status(self, client) -> StepStatus:
        current = await operations.read_device_name(client)
        if current and current != DEFAULT_DEVICE_NAME:
            return StepStatus(done=True, summary=current)
        return StepStatus(done=False, summary=f"{current or 'unset'} (factory default)")

    async def run(self, client, prompt) -> None:
        value = await prompt.text("Device name")
        error = validate_device_name(value)
        if error is not None:
            prompt.info(f"Invalid name: {error}")
            raise SetupCancelled
        await operations.rename_device(client, value)

The pipeline is then just a list, run in order:

def default_steps() -> list[SetupStep]:
    return [FirmwareStep(), WifiStep(), TimezoneStep(), NameStep(), CloudStep()]

The wizard reads every step's status() concurrently, prints the checklist, and calls run() only on what's still pending — which is why re-running it after a reboot is safe.

These two blocks are implementation fragments rather than standalone scripts: they define a setup step and its order, so they have no direct terminal output. When the wizard runs them, the observable result is the setup checklist shown in the quick start, with a completed name step rendered as [x] Device name ....

Why this shape

Splitting the device work out from the conversation buys three things:

  • The operations are reusable. Nothing in them assumes a wizard.
  • They're testable without a terminal. The wizard's tests drive them with a fake client and a scripted prompt, and the tricky cases above — a stale update check, a refused scan — are covered directly.
  • The documentation stays true. These examples are generated from the source, so a change to the program updates this page.

The full source is in examples/setup.