Program lifecycle

User programs are plain Python scripts executed by the on-device runner (ports/b2op/app/Runner.cpp). Write a normal script — no special decorators are needed.

Run program menu

The on-device Run program menu browses /sd (or / when the Full FS root setting is on). Every file and folder is listed — nothing is hidden, including dot-files, main.py and boot.py. The hub has no Python main app: main.py / boot.py are not run at boot and have no special meaning except as a project entry point (below). A freshly formatted filesystem starts empty (no boot.py is created).

Each entry shows an icon and its name:

Icon

Entry

Buttons

▶ (play)

Project — folder with a main.py file

centre: run <folder>/main.py; right: open the folder

Folder

Plain folder

centre / right: open the folder

File

.py / .mpy script

centre / right: run the script

File

Any other file

listed only; selecting does nothing

Inside a folder, the header shows its path; long paths scroll. With Full FS root off the path is relative to the SD card (/robot for /sd/robot); with it on, the full VFS path is shown (/sd/robot).

Left goes back up one level. A project’s main.py runs as a normal program, exactly as if it had been selected directly.

Imports and sys.path

While a program runs, its folder is inserted at sys.path[0], so a project can split code across files and import them from main.py:

/sd/robot/
├── main.py      # import drive
└── drive.py

When the program exits, the folder is removed from sys.path and every module loaded from that folder is dropped from sys.modules — the next run re-imports them from disk, so edited files take effect and two projects with a same-named module don’t clash. Scripts directly in the filesystem root (/) get no extra entry (/ is already the current directory).

Running a program

The runner execs the file and waits for it to finish. A program that needs to keep running (sensor polling, animation) uses its own while True loop and time.sleep_ms / time.sleep:

import hub, time

hub.led.setColorIdx(lpf2.color.GREEN)

while True:
    print(hub.imu.pitch)
    time.sleep_ms(10)

Note

Always include a small sleep (time.sleep_ms(5) or longer) in tight loops. MicroPython’s internal scheduler — which delivers button callbacks, STOP commands, and KeyboardInterrupt — only runs at safe points such as time.sleep_ms. A loop with no yield can prevent the host from stopping a runaway program.

Button callbacks (hub.buttons.on()) fire automatically — the C firmware detects rising edges in the hardware loop and the MicroPython task starts each callback in its own thread. No poll call is needed in user code. hub.stop_callbacks() stops callbacks still running; at program end the runner stops them (100 ms grace) before deleting all remaining user threads.

Exiting a program

  • Return from top-level code or raise SystemExit — the runner shows the “Done” screen. Programs are responsible for deciding when to quit.

  • Hold the centre button >= 2 s — the C firmware schedules a KeyboardInterrupt; the runner catches it and shows “Done”.

  • The runner also catches any unhandled Exception and shows the “Error” screen with its repr.

Powering off

  • Hold the centre button >= 5 s — hardware power-off from C.

  • Call hub.powerOff() — immediate power-off from Python. Does not return.

If the hardware power latch does not hold (e.g. because USB power is present), the firmware performs an orderly shutdown:

  1. Disables LPF2 ports and zeroes all motor PWM outputs.

  2. Turns off the LCD backlight.

  3. Deinitialises BLE and WiFi.

  4. Signals the MicroPython task to exit gracefully — the garbage collector runs before the task deletes itself.

  5. Throttles the CPU to 80 MHz.

  6. Enters a low-activity loop that keeps the battery status LED updated every 2 s.

Pressing the right button restarts the firmware.

Background tasks

Two C FreeRTOS tasks run alongside the MicroPython task and handle communication and hardware polling with no Python involvement:

  • Protocol task (hub_protocol_task, core 0) — reads USB CDC and BLE NUS input every 5 ms (1 ms while a transfer is active), reassembles frames, and dispatches commands (RUN, STOP, UPLOAD, …). Each poll is non-blocking: file transfers advance one bounded slice per poll, so other commands (and ABORT) are always answered. Runs continuously regardless of what the user script is doing.

  • Hub hardware loop (hub_main_task, core 0) — polls IMU, battery, and power button every 5 ms; detects button rising edges and schedules a dispatcher on the MicroPython task via mp_sched_schedule, which starts each registered callback in its own thread.

No manual poll wiring is needed — but include a time.sleep_ms in any tight loop so the MicroPython scheduler can run (see the note in Running a program above).

Log level

hub.log (hub._log_module) controls the firmware log verbosity:

hub.log.setLevel(2)   # 0=none, 1=err, 2=warn, 3=info, 4=debug, 5=verbose