Sending USB commands without freezing your prototype

You have a working desk device and want a laptop to change a setting or ask for a reading. A USB cable looks like the easy part. Then the device stops checking its buttons while it waits for a command, or the laptop receives a mixture of results and debugging messages.

That is worth solving before handing the prototype to someone else. In a Raspberry Pi forum discussion from November 2022, a builder wanted a Pico to respond to either a command from the computer or a physical push button, and waiting for serial input got in the way of the button. The same issue applies to a small test fixture, an interactive exhibit or a product configuration tool: receiving data should not take over the rest of the device.

On our bench we started with a small MicroPython experiment on a WIZnet RP2-based board: send typed text to the board over USB, echo it back and print a timing value. Following that exchange shows what a working USB connection gives you, and what you still need to build around it.

Start with the behavior the computer needs

Here the computer is the USB host and the board is a device. The firmware provides a virtual serial port using USB CDC, the communications device class. On Windows the user sees a COM port and can open it in a terminal. There is no separate USB-to-UART chip in this path; the board's native USB carries the exchange.

That is a practical first interface when your own computer program will send commands and read text replies. A device that should type into an existing application needs a USB keyboard function instead, and one that should appear as a drive needs a storage function. Those are different firmware choices even though the plug is the same. We tested CDC only.

Inside USB, data moves through endpoints, small communication destinations on the device, while your application works with a serial stream. Microsoft's endpoint guide explains why IN means data toward the host and OUT means data away from it.

Reproduce the small exchange first

Our program, written for MicroPython 1.23.0, waits three seconds, prints a startup message and registers standard input with select.poll(). Standard input and output are the console streams carried over the built-in USB serial interface. The core loop is:

poller = select.poll()
poller.register(sys.stdin, select.POLLIN)

while True:
    if poller.poll(1):
        data = input("please input data : ")
        start = time.ticks_us()
        print("received data : " + data)
        print("time cost :", time.ticks_diff(time.ticks_us(), start) / 1000, "ms")

We opened mpremote, typed short values from 11 to 99 and 00, and got matching replies on COM3. A later recorded session on COM50 began with an earlier serial-port error, then reconnected and echoed successfully. Keep that distinction when you reproduce it: an old error in terminal history is not the result of the exchanges that follow. Your port name will differ. The useful first result is that the board receives a complete line and the computer can read the reply. It does not yet show the device handling its physical controls while commands arrive.

Why a one-millisecond poll can still leave you waiting

poller.poll(1) waits up to one millisecond for a stream event. It does not put a one-millisecond limit on what follows. Once the code calls input(), it waits for a complete line. If the computer sends half a command and pauses before the newline, the program stays inside that call.

Switching to sys.stdin.readline().strip() does not fix this on its own; reading a line still waits for its terminator, and stripping whitespace afterwards changes nothing about the wait. A January 2025 MicroPython issue reports the related surprise that a stream can pass a poll check and then block on an unbounded read. The MicroPython stream documentation is the reference for blocking behavior, not the function name.

For short text commands, one approach is to collect available bytes into a small buffer and act on a command only when its newline arrives, returning to other work in between. Bound both the buffer size and the number of bytes handled per pass so a long or continuous message cannot monopolize the device. A minimal version:

stream = sys.stdin.buffer
line = bytearray()

def poll_commands(max_bytes=16):
    for _ in range(max_bytes):
        if not poller.poll(0):
            return
        b = stream.read(1)
        if not b or b == b"\r":
            continue
        if b == b"\n":
            handle(line.decode())
            line[:] = b""
        elif len(line) < 64:
            line.extend(b)

Call it from the main loop alongside your button and sensor updates. In a real version, also discard non-printable or oversized lines with an error reply, and clear an unfinished line after a timeout. This is a starting point to test on your firmware, not code from our recorded run, and output calls can still wait.

For an actual control command, replace the echo with a small set of named actions and check each argument before changing anything. A reply such as OK SET_RATE 10 tells the computer more than a copy of the received text. This pattern suits short text; do not push an arbitrary binary stream through the same console the REPL uses.

Read the timing number for what it measures

Our screenshot reported an average of 0.3085 ms over ten output calls. Look at the code before quoting it: the timer starts after input() returns and stops after print() returns. It excludes waiting for the command and says nothing about when the laptop receives or displays the reply.

So it is a local timing observation, not round-trip latency or USB throughput. For context, full-speed USB schedules traffic in 1 ms frames (125 µs microframes at high speed), but that bus scheduling does not tell you how long a Python handler or the host application takes. Swapping print() for sys.stdout.write() changes formatting, not delivery.

If response time matters, measure it where the user feels it. Have the computer send a numbered request and time the matching reply. Repeat while the board does its intended sampling or control work, and include delayed, incomplete and repeated commands. We have not run those tests yet.

Make reconnecting part of the interface

Before the laptop can open the port, it has to enumerate the device: identify it, assign an address and select a configuration. A cable connection, a visible serial port and a running command handler are three separate checkpoints, and success at one does not prove the next.

Some older guidance says RP2 boards cannot define USB interfaces at runtime. That should not drive a new design: the MicroPython 1.23.0 USBDevice documentation already lists RP2 support. Check your actual firmware build before choosing between the built-in console and an added interface.

An extra CDC interface helps when a computer application needs its own data channel while a developer keeps the console. MicroPython's USB CDC library provides a higher-level interface than writing descriptors yourself. We have not tested that route. If you take it, plan for resets: the USBDevice documentation explains that a soft reset clears runtime interfaces and can disconnect the device, and recommends configuring persistent interfaces in boot.py. The computer application must notice a lost connection, reopen the right device and re-establish state rather than assume an earlier command is still active.

The next useful prototype test

Take one action the laptop must request, such as changing a sample interval, starting a measurement or reading a setting, and give it a defined command and reply. Then try it while pressing the device's buttons. Send half a command and wait. Unplug and reconnect the cable. The test passes when both the person and the computer can tell what the device is doing, including after an interrupted exchange.