Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

nxt-rs is a Rust library for controlling LEGO MINDSTORMS NXT bricks from a computer. It talks to the brick’s stock firmware over USB or Bluetooth, using the Direct and System commands LEGO documented in the NXT Bluetooth Developer Kit, so you don’t need to install anything on the brick.

use nxt_rs::prelude::*;

fn main() -> nxt_rs::Result<()> {
    let brick = Brick::usb_first().block()?;
    println!("Hello, {}! Battery: {}", brick.name().block()?, brick.battery().block()?);

    let motor = brick.motor(A)?;
    motor.run(power!(75)).degrees(degrees!(360)).wait().block()?;
    Ok(())
}

What you get

  • Two APIs, one connection. The high-level API gives you motors, sensors, files and messages. The low-level API gives you every protocol command as a typed method. You can mix both freely.
  • Mistakes caught by the compiler.
    • A motor can’t be plugged into a sensor port.
    • Two devices can’t share a port within a group.
    • Motor power can’t be out of range.
    • You can’t wait for a motor that never stops.
    • USB-only commands can’t be sent over Bluetooth.
  • Readings with meaning. A touch sensor returns bool, a light sensor a Percent, the ultrasonic sensor Option<Centimeters>, and the colour sensor a ColorName.
  • Async or not, your choice. Everything is async and works with any runtime. Add .block() to use it from plain synchronous code.
  • Testable without a robot. A built-in simulated brick runs your code in unit tests and CI.
  • USB, Bluetooth and serial, on Linux, macOS and Windows.

How this book is organised

  • Getting Started takes you from cargo new to a moving robot.
  • Concepts explains the ideas the API is built on. Read it once and the rest will feel obvious.
  • Connecting, High-level API and Low-level API cover each part of the library in depth.
  • Guides covers testing, complete example robots and troubleshooting.
  • Reference has tables of features, protocol details, status codes and limits.

The API documentation (cargo doc --open) complements this book with every type and method. Every code example in this book is compiled and run as part of the crate’s test suite, so they stay correct.

Requirements

  • A LEGO MINDSTORMS NXT brick (1.0 or 2.0) with LEGO’s standard firmware (or an NBC/NXC “enhanced” firmware). The NXT 2.0 colour sensor needs firmware 1.28 or newer.
  • Rust 1.85 or newer (edition 2024).

Installation

Set up a development environment (optional)

If you use devenv, a minimal setup gives you Rust plus the system libraries the bluetooth feature needs. In a new project directory, run devenv init, then replace the generated devenv.nix with:

{
  pkgs,
  lib,
  config,
  inputs,
  ...
}: {
  languages.rust.enable = true;
  packages = with pkgs; [pkg-config dbus];
}

Enter the environment with devenv shell (or automatically, with direnv). pkg-config and dbus are only needed for the bluetooth feature, but are harmless otherwise. Without devenv, install Rust with rustup.

Add the crate

nxt-rs is not on crates.io yet. Add it from its git repository:

cargo new my-robot
cd my-robot
cargo add nxt-rs --git https://forge.wvffle.net/wvffle/nxt-rs.git

The default features include USB support. For Bluetooth or serial ports, enable the matching feature:

cargo add nxt-rs --git https://forge.wvffle.net/wvffle/nxt-rs.git --features bluetooth   # Linux, via BlueZ
cargo add nxt-rs --git https://forge.wvffle.net/wvffle/nxt-rs.git --features serial      # any OS, via a Bluetooth serial port

Your Cargo.toml then contains:

[dependencies]
nxt-rs = { git = "https://forge.wvffle.net/wvffle/nxt-rs.git", features = ["serial"] }

See Cargo features for the full list.

Platform setup

USB access needs a little setup on Linux and Windows. macOS works out of the box.

Linux

On Linux, only root may access USB devices by default. Give your user access with a udev rule (recommended), or run your programs as root (see Without udev rules).

NixOS

Add the rule to your system configuration:

# configuration.nix
services.udev.extraRules = ''
  # LEGO MINDSTORMS NXT
  SUBSYSTEM=="usb", ATTRS{idVendor}=="0694", ATTRS{idProduct}=="0002", MODE="0660", TAG+="uaccess"
'';

Apply it with sudo nixos-rebuild switch, then unplug and replug the brick. The uaccess tag gives the user logged in at the machine access.

For Bluetooth, enable BlueZ with hardware.bluetooth.enable = true;. The bluetooth feature also needs dbus and pkg-config at build time. The devenv setup above provides them, or use nix-shell -p pkg-config dbus.

Other Linux distributions

Install the udev rule that ships with the crate (contrib/70-lego-nxt.rules in the repository):

SUBSYSTEM=="usb", ATTRS{idVendor}=="0694", ATTRS{idProduct}=="0002", MODE="0660", GROUP="plugdev", TAG+="uaccess"
sudo cp contrib/70-lego-nxt.rules /etc/udev/rules.d/
sudo udevadm control --reload-rules

Then unplug and replug the brick. The uaccess tag gives the logged-in user access. On headless machines, where nobody is logged in at the console, the plugdev group grants access instead. Add yourself with sudo usermod -aG plugdev $USER and log in again. Some distributions, such as Fedora and Arch, don’t have that group: create it first with sudo groupadd plugdev.

For Bluetooth, BlueZ must be installed and running (sudo systemctl enable --now bluetooth). The bluetooth feature also needs the D-Bus development files and pkg-config:

DistributionPackages
Debian, Ubuntulibdbus-1-dev pkg-config
Fedoradbus-devel pkgconf-pkg-config
Archdbus pkgconf

Without udev rules: run as root

If you’d rather not install a udev rule, for example on a machine you don’t administer permanently, build as your normal user and run only the finished program as root:

cargo build --release
sudo ./target/release/my-robot

Don’t run cargo itself with sudo. Root usually has no Rust toolchain, and the build would leave root-owned files in target/.

To keep using cargo run (and cargo test), tell cargo to start the compiled program through sudo. Create .cargo/config.toml in your project:

[target.'cfg(target_os = "linux")']
runner = "sudo"

Cargo now builds as you and runs sudo target/debug/my-robot, asking for your password when needed. To pass environment variables such as RUST_LOG through, use runner = ["sudo", "--preserve-env=RUST_LOG"].

Only USB needs this. Bluetooth access goes through BlueZ and works as a normal user. A udev rule is still the better long-term solution: programs running as root can do anything to your system.

Windows

The brick needs the generic WinUSB driver. LEGO’s own driver isn’t supported. Install it with Zadig: plug in the brick, choose Options → List All Devices, select LEGO MINDSTORMS NXT, choose WinUSB and click Replace Driver.

macOS

Nothing to do.

Check that it works

Plug in the brick, switch it on, and run:

use nxt_rs::prelude::*;

fn main() -> nxt_rs::Result<()> {
    let brick = Brick::usb_first().block()?;
    println!("Connected to {}", brick.name().block()?);
    Ok(())
}

If you see no NXT brick found, check Troubleshooting.

Next: Connecting your brick.

Connecting your brick

Every program starts by opening a Brick. How you open it depends on the cable, or lack of one.

LinkCodeFeature
USB, first brick foundBrick::usb_first().await?usb (default)
USB, every brickBrick::usb_all().await?usb (default)
Bluetooth (Linux)Brick::bluetooth(address).await?bluetooth
Bluetooth serial port (any OS)Brick::serial("/dev/rfcomm0").await?serial
Whatever is availablenxt_rs::connect_any().await?—
Simulated brickBrick::mock()—
use nxt_rs::prelude::*;

fn main() -> nxt_rs::Result<()> {
let brick = Brick::usb_first().block()?;

let info = brick.info().block()?;
println!("name:      {}", info.name);
println!("bluetooth: {}", info.bluetooth_address);
println!("battery:   {}", brick.battery().block()?);
println!("firmware:  {}", brick.firmware_version().block()?.firmware);
Ok(()) }

USB is the fastest and simplest option. A command round trip takes a few milliseconds over USB and about 60 ms over Bluetooth. Use Bluetooth when the robot has to move around freely.

The Connecting chapters cover each link in detail, including Bluetooth pairing.

One connection, many handles

Brick is cheap to clone. All clones share the same connection, so you can hand them to different tasks or threads. Requests from different clones never mix up their replies.

nxt_rs::doc_example(|brick| async move {
let for_logging = brick.clone();
assert!(brick.same_brick(&for_logging));
Ok(()) });

Next: Your first program.

Your first program

This chapter builds a small robot program step by step: drive forward until the touch sensor is pressed, back off, beep and turn around.

You need a brick with:

  • motors on outputs B (left) and C (right), as in the standard driving base,
  • a touch sensor on input 1.

No robot at hand? Every snippet below also runs against the simulator.

1. Connect

use nxt_rs::prelude::*;

fn main() -> nxt_rs::Result<()> {
    let brick = Brick::usb_first().block()?;
    println!("connected to {}", brick.name().block()?);
    Ok(())
}

nxt_rs::prelude::* imports what most programs need: Brick, the port names A–C and S1–S4, the power!/degrees! macros, unit types and the .block() helper.

2. Take the devices

Before you can use a motor or sensor, you take its port. That configures the port on the brick and gives you a typed handle:

nxt_rs::doc_example(|brick| async move {
use nxt_rs::prelude::*;
let left = brick.motor(B)?;
let right = brick.motor(C)?;
let bumper = brick.touch(S1).await?;
Ok(()) });

Each port can only be taken once. A second brick.motor(B) returns Error::PortTaken until the first motor is dropped. You can also take several devices in one go. Using the same port twice in one group is a compile error:

nxt_rs::doc_example(|brick| async move {
use nxt_rs::prelude::*;
let (left, right, bumper) = brick.take((B.motor(), C.motor(), S1.touch())).await?;
Ok(()) });

The compiler also knows which ports are which. brick.motor(S1) doesn’t compile, because S1 is a sensor input. Ports and devices explains all of this.

3. Drive

The two drive motors should turn in step, so pair them:

nxt_rs::doc_example(|brick| async move {
use nxt_rs::prelude::*;
let (left, right) = brick.take((B.motor(), C.motor())).await?;
let mut drive = SyncedPair::new(left, right);
drive.forward(power!(60)).await?;          // runs until told otherwise
Ok(()) });

power!(60) is a Power checked at compile time: power!(150) won’t compile. For values computed at run time, use Power::new(x)?.

4. React to the sensor

nxt_rs::doc_example_with(|brick, sim| async move {
use nxt_rs::prelude::*;
use std::time::Duration;
let bumper = brick.touch(S1).await?;
sim.set_sensor(InputPort::S1, 1, 1);
bumper.wait_for_press(Duration::from_millis(10)).await?;
Ok(()) });

Or poll it yourself: bumper.read().await? returns a bool.

5. Back off, beep, turn

nxt_rs::doc_example(|brick| async move {
use nxt_rs::prelude::*;
use nxt_rs::turn_ratio;
use std::time::Duration;

let (left, right) = brick.take((B.motor(), C.motor())).await?;
let mut drive = SyncedPair::new(left, right);
drive.forward(power!(-50)).degrees(degrees!(360)).brake().wait().await?;
brick.play_tone(Hz::A4, Duration::from_millis(200)).await?;
drive.steer(power!(50), turn_ratio!(100)).degrees(degrees!(460)).brake().wait().await?;
Ok(()) });
  • .degrees(..) stops the motors after that much wheel rotation.
  • .brake() stops them firmly instead of letting them coast.
  • .wait() waits until the movement is done. It’s only available after .degrees(..), because waiting for a motor that never stops would hang forever. The compiler enforces this.
  • A turn ratio of 100 spins the robot in place.

The whole program

use std::time::Duration;

use nxt_rs::{prelude::*, turn_ratio};

fn main() -> nxt_rs::Result<()> {
    let brick = Brick::usb_first().block()?;
    let (left, right, bumper) = brick.take((B.motor(), C.motor(), S1.touch())).block()?;
    let mut drive = SyncedPair::new(left, right);

    for _ in 0..5 {
        drive.forward(power!(60)).block()?;
        bumper.wait_for_press(Duration::from_millis(10)).block()?;

        drive.forward(power!(-50)).degrees(degrees!(360)).brake().wait().block()?;
        brick.play_tone(Hz::A4, Duration::from_millis(200)).block()?;
        drive.steer(power!(50), turn_ratio!(100)).degrees(degrees!(460)).brake().wait().block()?;
    }

    brick.stop_all().block()?;
    Ok(())
}

brick.stop_all() stops all motors, silences the speaker and unconfigures the sensors. Calling it at the end is good manners.

The same program, async

With an async runtime such as tokio, .await instead of .block():

use std::time::Duration;

use nxt_rs::{prelude::*, turn_ratio};

#[tokio::main]
async fn main() -> nxt_rs::Result<()> {
    let brick = Brick::usb_first().await?;
    let (left, right, bumper) = brick.take((B.motor(), C.motor(), S1.touch())).await?;
    let mut drive = SyncedPair::new(left, right);

    loop {
        drive.forward(power!(60)).await?;
        bumper.wait_for_press(Duration::from_millis(10)).await?;
        drive.forward(power!(-50)).degrees(degrees!(360)).brake().wait().await?;
        drive.steer(power!(50), turn_ratio!(100)).degrees(degrees!(460)).brake().wait().await?;
    }
}

The library works with any async runtime: tokio, async-std, smol, or none at all.

Where next?

Working without hardware

nxt-rs includes a simulated brick. It’s always compiled in and needs no feature flag. It answers every protocol command plausibly:

  • Motors with a rotation limit finish instantly and update their counters.
  • Sensors report whatever values you set.
  • Files, mailboxes, I²C registers and the screen live in memory.
use nxt_rs::{prelude::*, transport::mock::Simulator};

fn main() -> nxt_rs::Result<()> {
let sim = Simulator::new();
let brick = Brick::mock_with(sim.clone());   // keep `sim` to control the brick

let motor = brick.motor(A)?;
motor.run(power!(50)).degrees(degrees!(720)).wait().block()?;
assert_eq!(sim.output(OutputPort::A).rotation_count, 720);

let touch = brick.touch(S1).block()?;
assert!(!touch.read().block()?);
sim.set_sensor(InputPort::S1, 1023, 1);      // press the button
assert!(touch.read().block()?);
Ok(()) }

Brick::mock() is shorthand for a simulator you don’t need to control.

Use the simulator to:

  • try the library before your brick arrives,
  • unit-test robot logic in CI,
  • develop on a laptop away from the robot.

Write your functions against a generic Brick<T> so that the same code runs on the real brick and in tests:

use nxt_rs::{prelude::*, transport::{Transport, mock::Simulator}};

/// Turns in small steps until the ultrasonic sensor sees something close.
async fn face_obstacle<T: Transport>(brick: &Brick<T>) -> nxt_rs::Result<()> {
    let (motor, sonar) = brick.take((A.motor(), S4.ultrasonic())).await?;
    while sonar.read().await?.is_none_or(|d| d > Centimeters(20)) {
        motor.run(power!(30)).degrees(degrees!(10)).wait().await?;
    }
    Ok(())
}

fn main() -> nxt_rs::Result<()> {
let sim = Simulator::new();
sim.set_ultrasonic_distance(InputPort::S4, 15);
face_obstacle(&Brick::mock_with(sim)).block()?;
Ok(()) }

The Testing chapter covers the simulator in depth, including byte-exact scripted tests.

Run cargo run --example simulator in the nxt-rs repository to see a complete program running against it.

The two API tiers

nxt-rs has two public APIs. Both work on the same Brick, connection, error type and value types, and you can switch between them at any point.

High level: the robot

For building robots. You work with devices, not protocol commands:

nxt_rs::doc_example(|brick| async move {
use nxt_rs::prelude::*;

let (motor, light) = brick.take((A.motor(), S3.light())).await?;
motor.run(power!(40)).degrees(degrees!(90)).wait().await?;
let level: Percent = light.read().await?;
Ok(()) });
  • Devices claim their ports, so two parts of your program can’t fight over one socket.
  • Sensor modes are part of the type, so readings come back as bool, Percent, Option<Centimeters> or ColorName.
  • Files, searches and module handles close themselves.

Low level: the protocol, typed

For tools, for porting code written against LEGO’s Bluetooth Developer Kit, and for anything the high-level API doesn’t model:

nxt_rs::doc_example(|brick| async move {
use nxt_rs::{port::{OutputPort, InputPort}, protocol::*, units::*};

let d = brick.direct();
d.set_output_state(OutputPort::A, OutputState {
    power: Power::new(40)?,
    mode: OutputMode::MOTOR_ON,
    run_state: RunState::Running,
    tacho_limit: TachoLimit::Degrees(Degrees::new(90)?),
    ..OutputState::default()
}).await?;
d.set_input_mode(InputPort::S3, SensorType::LightActive, SensorMode::PercentFullScale).await?;
let values: InputValues = d.get_input_values(InputPort::S3).await?;
Ok(()) });
  • brick.direct() and brick.system() have one method per protocol command, with typed arguments and decoded replies.
  • brick.send(command) sends any Command, including ones you define. brick.raw_transact(bytes) deals in raw bytes.
  • Ports are plain enums (OutputPort::A, InputPort::S3) and handles are plain numbers. Nothing is claimed and nothing is cleaned up for you.

Mixing the tiers

The high-level API is built only on the public low-level API. Anything it does, you can do too, and you can drop down at any time:

From → toHow
high → lowmotor.port(), sensor.port() give the runtime port; motor.brick() gives the brick; reader.handle() gives the raw file handle
low → highbrick.motor(A), brick.port(S1) at any time; FileReader::from_handle / FileWriter::from_handle adopt raw handles
nxt_rs::doc_example(|brick| async move {
use nxt_rs::prelude::*;

let motor = brick.motor(C)?;
motor.run(power!(30)).degrees(degrees!(45)).wait().await?;

// Drop down for the full status record:
let status = motor.brick().direct().get_output_state(motor.port()).await?;
println!("block tacho count: {}", status.block_tacho_count);
Ok(()) });

A low-level call on a port that a high-level device owns won’t break the library. It may, however, change the device’s state behind its back. For example, set_input_mode on a light sensor’s port changes the mode its type claims. Do it knowingly.

Ports and devices

An NXT has three motor outputs (A, B, C) and four sensor inputs (1–4). In nxt-rs each one is a value and a type:

PortValue / typeLow-level enum
Output A, B, CA, B, COutputPort::A, …
Input 1–4S1, S2, S3, S4InputPort::S1, …

The values are zero-sized: they only carry their type. Because each port has its own type, a device’s type records which socket it is on. A motor on output B is a Motor<B, T>, and a light sensor on input 3 is a Light<S3, T, Reflected>. (T is the transport.)

Taking devices

A device claims its port. Take devices one at a time:

nxt_rs::doc_example(|brick| async move {
use nxt_rs::prelude::*;

let left = brick.motor(B)?;
let bumper = brick.touch(S1).await?;
let sonar = brick.ultrasonic(S4).await?;
Ok(()) });

Or several at once with take and a tuple of specs:

nxt_rs::doc_example(|brick| async move {
use nxt_rs::prelude::*;

let (left, right, bumper, sonar) =
    brick.take((B.motor(), C.motor(), S1.touch(), S4.ultrasonic())).await?;
Ok(()) });
DeviceOne at a timeSpec for take
Motorbrick.motor(B)?B.motor()
Touch sensorbrick.touch(S1).await?S1.touch()
Light sensorbrick.light(S3).await?S3.light()
Sound sensorbrick.sound(S2).await?S2.sound()
Colour sensorbrick.color(S3).await?S3.color()
Ultrasonic sensorbrick.ultrasonic(S4).await?S4.ultrasonic()
Any analog sensorbrick.raw_sensor(S2, ty, mode).await?S2.raw(ty, mode)
I²C device Dbrick.i2c::<D, _>(S4).await?S4.i2c::<D>()
Raw I²C busbrick.i2c_bus(S4, address, powered).await?—
Bare portbrick.port(S2)?S2.port()

Sensor getters are async because they configure the port on the brick. Motors need no configuration, so brick.motor is synchronous.

What the compiler checks

Wrong kind of port. Motors only go on outputs and sensors only on inputs:

async fn f(brick: nxt_rs::Brick<nxt_rs::transport::Mock>) -> nxt_rs::Result<()> {
use nxt_rs::prelude::*;
let motor = brick.motor(S1)?;      // error: `S1` is not a motor output
Ok(()) }
async fn f(brick: nxt_rs::Brick<nxt_rs::transport::Mock>) -> nxt_rs::Result<()> {
use nxt_rs::prelude::*;
let (m,) = brick.take((S1.motor(),)).await?;   // error: no method `motor` on `S1`
Ok(()) }

A port used twice in one group:

async fn f(brick: nxt_rs::Brick<nxt_rs::transport::Mock>) -> nxt_rs::Result<()> {
use nxt_rs::prelude::*;
let (a, b) = brick.take((B.motor(), B.motor())).await?;   // error: port `B` is taken twice
Ok(()) }

Using a device after giving it away. Switching a sensor’s mode, or converting a port, consumes the old value:

async fn f(brick: nxt_rs::Brick<nxt_rs::transport::Mock>) -> nxt_rs::Result<()> {
use nxt_rs::prelude::*;
let light = brick.light(S3).await?;
let ambient = light.ambient().await?;
light.read().await?;               // error: use of moved value `light`
Ok(()) }

What is checked at run time

Taking a port that is already claimed returns Error::PortTaken. That happens when separate calls, or separate parts of your program, ask for the same port:

nxt_rs::doc_example(|brick| async move {
use nxt_rs::{prelude::*, Error};

let motor = brick.motor(B)?;
assert!(matches!(brick.motor(B), Err(Error::PortTaken(_))));
Ok(()) });

A take group claims all its ports at once. If any of them is in use, nothing is claimed. If configuring a device fails halfway, the ports claimed so far are released again.

Releasing ports

A claim lasts as long as the device. Dropping a device releases its port:

nxt_rs::doc_example(|brick| async move {
use nxt_rs::prelude::*;

let motor = brick.motor(B)?;
drop(motor);
let motor_again = brick.motor(B)?;   // fine
Ok(()) });

To reuse a port for something else without letting go of it, call release(). It resets the hardware (coasts the motor, or unconfigures the sensor) and gives back the still-claimed Port:

nxt_rs::doc_example(|brick| async move {
use nxt_rs::prelude::*;

let touch = brick.touch(S1).await?;
let port = touch.release().await?;        // still claimed by us
let light = port.into_light().await?;     // same socket, new device
Ok(()) });

Inspecting claims

brick.port_claims() shows which ports are in use. It is public so that abstractions you build on the low-level API can take part in the same claim system:

nxt_rs::doc_example(|brick| async move {
use nxt_rs::{prelude::*, port::AnyPort};

let _motor = brick.motor(B)?;
assert_eq!(brick.port_claims().claimed(), [AnyPort::Output(OutputPort::B)]);
Ok(()) });

Claims belong to a Brick and are shared by all its clones. They only apply to this library within this process. They can’t stop another program, or a program running on the brick, from using the same port.

Values and units

The NXT protocol is full of numbers with restricted ranges: motor power is −100…100, tone frequencies 200…14 000 Hz, file names at most 15+3 characters. nxt-rs wraps each one in a small type that can only hold valid values. You validate once, when you create the value, and the rest of your program can rely on it.

Creating values

Each type has a fallible constructor. Literals also have a macro that checks at compile time:

use nxt_rs::{power, degrees, turn_ratio, units::*};

fn main() -> nxt_rs::Result<()> {
let fast = power!(80);                 // checked by the compiler
let computed = Power::new(-35)?;       // checked at run time
let clamped = Power::saturating(250);  // clamped to 100
assert_eq!(clamped, Power::MAX);

let quarter = degrees!(90);
let two_turns = Degrees::from_rotations(2)?;
let spin = turn_ratio!(100);
Ok(()) }
let too_fast = nxt_rs::power!(101);    // error: power must be in -100..=100
let nothing = nxt_rs::degrees!(0);     // error: degrees must be non-zero

The types

TypeRangeNotes
Power−100 … 100power!, Power::new, Power::saturating, .reverse(), -p
TurnRatio−100 … 100turn_ratio!; 0 straight, ±50 one wheel stopped, ±100 spin
Degrees1 … u32::MAXdegrees!, Degrees::from_rotations
TachoLimitUnlimited or Degrees(d)“Run forever” is its own variant, never 0
Hz200 … 14 000Note constants Hz::C4 … Hz::C5
Millis160 … 65 535 msFrom Duration with try_from
Percent0 … 100Light and sound readings
Centimeters0 … 254Ultrasonic distance
Millivoltsany u16Battery voltage; .volts()

Protocol strings and payloads work the same way:

TypeRule
FileNameprintable ASCII, at most 15 characters + . + 3 (* allowed for searches)
BrickNameASCII, 1–15 characters
Messageat most 58 bytes
LsBytesat most 16 bytes (one I²C transfer)
MailboxM1 … M10
I2cAddresseven (8-bit form)
Slope0 … 31
use nxt_rs::protocol::{FileName, Message};

fn main() -> nxt_rs::Result<()> {
let program: FileName = "robot.rxe".parse()?;
assert_eq!(program.extension(), Some("rxe"));
assert!(FileName::new("a-very-long-file-name.rxe").is_err());

let msg = Message::new("hello")?;
assert_eq!(msg.as_str(), Some("hello"));
Ok(()) }

Errors

A failed constructor returns a ValueError naming the value and its valid range. ? converts it into nxt_rs::Error::Value:

use nxt_rs::units::Power;

let err = Power::new(101).unwrap_err();
assert_eq!(err.to_string(), "invalid power: must be in -100..=100");

Async and blocking code

Every operation that talks to the brick is a Future. That lets several tasks share one brick, drive motors concurrently and wait without blocking threads.

With an async runtime

nxt-rs works with any runtime: tokio, async-std, smol, embassy-style executors, or a simple block_on. Just .await:

use nxt_rs::prelude::*;

#[tokio::main]
async fn main() -> nxt_rs::Result<()> {
    let brick = Brick::usb_first().await?;
    println!("battery: {}", brick.battery().await?);
    Ok(())
}

The Bluetooth transport is the one exception: its backend (bluer) needs a tokio runtime.

Without one: .block()

Import nxt_rs::blocking::Block (it’s in the prelude) and add .block() to any future or builder:

use nxt_rs::prelude::*;

fn main() -> nxt_rs::Result<()> {
let brick = Brick::mock(); // or Brick::usb_first().block()?
let battery = brick.battery().block()?;
let motor = brick.motor(A)?;
motor.run(power!(50)).degrees(degrees!(90)).wait().block()?;
let raw = brick.direct().get_battery_level().block()?;
Ok(()) }

.block() works the same on both API tiers, so there is no separate “blocking” copy of the API to learn. With the bluetooth feature enabled, it runs futures on a shared tokio runtime. Otherwise it uses a lightweight executor.

Don’t call .block() from inside an async task: it blocks that task’s thread. Use .await there.

nxt_rs::blocking::SyncIo adapts the async file reader and writer to std::io::Read/Write. See Files.

Concurrency

Brick is Clone + Send + Sync. Each request and its reply happen under an internal lock, so concurrent tasks never receive each other’s replies:

nxt_rs::doc_example(|brick| async move {
use nxt_rs::prelude::*;
use futures_lite::future::zip;

let (a, b) = brick.take((A.motor(), B.motor())).await?;
// Start both motors concurrently.
let (ra, rb) = zip(a.run(power!(50)).into_future(), b.run(power!(-50)).into_future()).await;
ra?;
rb?;
Ok(()) });

The brick handles one command at a time, so concurrency saves waiting time in your program, not time on the wire.

Background keep-alive

The NXT switches itself off after its sleep timeout. To keep it awake during a long program, spawn brick.keep_alive_task(interval) on your runtime:

async fn f(brick: nxt_rs::Brick<nxt_rs::transport::Usb>) {
use std::time::Duration;
tokio::spawn(brick.keep_alive_task(Duration::from_secs(60)));
}

Errors

All fallible operations return nxt_rs::Result<T>, an alias for Result<T, nxt_rs::Error>.

VariantMeaning
Error::Transport(e)The USB/Bluetooth/serial link failed: unplugged, permission denied, …
Error::TimeoutThe brick didn’t answer in time (Brick::with_timeout)
Error::Brick { kind, opcode, status }The brick rejected a command, with a typed Status
Error::Decode(e) / Error::Encode(e)A telegram was malformed (rare; usually a bug or a noisy link)
Error::Value(e)A value was out of range, e.g. Power::new(150)
Error::PortTaken(port)The port is already claimed by another device

Handling brick errors

error.status() gives the brick’s status code, if the brick sent one:

nxt_rs::doc_example(|brick| async move {
use nxt_rs::{Status, protocol::FileName};

match brick.files().read(&FileName::new("missing.txt")?).await {
    Ok(data) => println!("{} bytes", data.len()),
    Err(e) if e.status() == Some(Status::FileNotFound) => println!("no such file"),
    Err(e) => return Err(e.into()),
}
Ok(()) });

Many “errors” are normal answers, and the high-level API turns those into ordinary values:

Low levelHigh level
stop_program() fails with NoActiveProgrambrick.stop_program() returns Ok(false)
get_current_program_name() fails with NoActiveProgrambrick.current_program() returns Ok(None)
message_read() fails with MailboxEmptymessages().recv() returns Ok(None)
find_first() fails with FileNotFoundfiles().list() returns an empty Vec

Status::is_retryable() tells you whether trying again later may help, for example while an I²C transfer is still running.

With anyhow and friends

nxt_rs::Error implements std::error::Error + Send + Sync + 'static, so it works with anyhow, eyre and Box<dyn Error>.

USB

USB is the default transport (feature usb). It’s implemented in pure Rust with nusb, so no libusb is required. See Installation for the one-time udev (Linux) or WinUSB (Windows) setup.

use nxt_rs::prelude::*;

fn main() -> nxt_rs::Result<()> {
let brick = Brick::usb_first().block()?;        // the first brick found
let bricks = Brick::usb_all().block()?;          // every connected brick
Ok(()) }

Choosing between several bricks

Usb::list() returns the bricks on the bus without opening them. The USB serial number is the brick’s Bluetooth address in hex, which is a stable way to tell bricks apart:

use nxt_rs::{Brick, blocking::Block, transport::Usb};

fn main() -> nxt_rs::Result<()> {
for device in Usb::list().block()? {
    println!("{:?} on bus {}", device.serial_number(), device.bus_id());
}

let wanted = Usb::list()
    .block()?
    .into_iter()
    .find(|d| d.serial_number() == Some("0016530A0B0C"))
    .expect("brick not connected");
let brick = Brick::new(wanted.open().block()?);
Ok(()) }

Details

  • The brick shows up as vendor 0x0694, product 0x0002, with bulk endpoints 0x01 (out) and 0x82 (in) on interface 0.
  • Only one program can open the brick at a time.
  • The default request timeout is 1 second.
  • USB-only commands, such as rebooting into firmware-update mode or resetting Bluetooth, are only available on a Brick<Usb>. See System commands.

Bluetooth (Linux)

The bluetooth feature connects over RFCOMM through BlueZ, the Linux Bluetooth stack, via bluer. On other operating systems, use a serial port instead.

[dependencies]
nxt-rs = { git = "https://forge.wvffle.net/wvffle/nxt-rs.git", features = ["bluetooth"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

Pairing

Pair the brick once with bluetoothctl. On the brick, make sure Bluetooth is switched on and the brick is visible (Bluetooth → Visibility → Visible).

$ bluetoothctl
[bluetooth]# scan on
[NEW] Device 00:16:53:0A:0B:0C NXT
[bluetooth]# pair 00:16:53:0A:0B:0C

The brick beeps and shows a passkey prompt. The default passkey is 1234. Confirm it on the brick and enter the same passkey on the computer when asked. Finally:

[bluetooth]# trust 00:16:53:0A:0B:0C

Connecting

use nxt_rs::{Brick, transport::Bluetooth};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    for nxt in Bluetooth::paired().await? {
        println!("{} at {}", nxt.name, nxt.address);
    }

    let brick = Brick::bluetooth("00:16:53:0A:0B:0C".parse()?).await?;
    println!("battery: {}", brick.battery().await?);
    Ok(())
}

Bluetooth::paired() lists the paired devices whose Bluetooth class is that of an NXT.

Things to know

  • tokio is required. bluer runs on tokio. Inside #[tokio::main] everything works. .block() starts a shared tokio runtime for you when the bluetooth feature is on.
  • Latency. Each request/reply round trip takes about 60 ms, because the brick’s Bluetooth chip needs time to switch direction. Batch work into fewer commands, use send_no_reply where you don’t need an answer, or run time-critical loops on the brick and talk to it with messages.
  • Default timeout: 3 seconds.
  • One connection at a time. The brick accepts a single Bluetooth connection from a computer.
  • The bluetooth feature is Linux-only. On Windows and macOS, use a serial port.

Serial ports

When an operating system pairs with an NXT, it can expose the brick’s serial port profile as a regular serial port. The serial feature talks to that port with serialport. It works on Linux, macOS and Windows, which makes it the portable Bluetooth option.

[dependencies]
nxt-rs = { git = "https://forge.wvffle.net/wvffle/nxt-rs.git", features = ["serial"] }
use nxt_rs::prelude::*;

fn main() -> nxt_rs::Result<()> {
let brick = Brick::serial("/dev/rfcomm0").block()?;
println!("battery: {}", brick.battery().block()?);
Ok(()) }

Finding the port

OSStepsPort
LinuxPair (see Bluetooth), then sudo rfcomm bind 0 00:16:53:0A:0B:0C 1/dev/rfcomm0
macOSPair in System Settings → Bluetooth/dev/tty.NXT-DevB (or similar, see ls /dev/tty.*)
WindowsPair in Settings → Bluetooth, then More Bluetooth settings → COM PortsThe outgoing port, e.g. COM5

Details

  • I/O runs on a small blocking thread pool, so it works with any async runtime.
  • If a request is cancelled while the port is busy (for example by a timeout), the transport reports Disconnected from then on. Reconnect by opening the port again.
  • The default timeout is 3 seconds.

Choosing at run time and custom transports

Brick<T> and the transport type

Brick is generic over its transport: Brick<Usb>, Brick<Bluetooth>, Brick<Serial>, Brick<Mock>. The type decides which commands are available. USB-only commands exist only on Brick<Usb> (and the mock).

To write code that works with any brick, be generic:

use nxt_rs::{Brick, transport::Transport};

async fn report<T: Transport>(brick: &Brick<T>) -> nxt_rs::Result<()> {
    println!("{} ({:?}): {}", brick.name().await?, brick.transport_kind(), brick.battery().await?);
    Ok(())
}
nxt_rs::doc_example(|brick| async move { report(&brick).await?; Ok(()) });

Deciding at run time: AnyTransport

When the link is chosen at run time, for example from a command-line flag, erase the type with AnyTransport:

use nxt_rs::{Brick, blocking::Block, transport::{AnyTransport, Mock, Usb}};

fn main() -> nxt_rs::Result<()> {
let simulate = std::env::args().any(|a| a == "--simulate");
let brick: Brick<AnyTransport> = if simulate {
    Brick::new(AnyTransport::new(Mock::simulator()))
} else {
    Brick::new(AnyTransport::new(Usb::first().block()?))
};
Ok(()) }

nxt_rs::connect_any() does this for you. It tries USB first, then the paired Bluetooth bricks (with the bluetooth feature).

A Brick<AnyTransport> can’t send USB-only commands, because the compiler can’t know the link is USB.

Writing your own transport

A transport moves complete telegrams: a type byte, an opcode and arguments. Implement nxt_rs::transport::Transport to bridge the brick over anything, for example a TCP relay:

use nxt_rs::{
    Brick,
    error::TransportError,
    protocol::MAX_TELEGRAM_LEN,
    transport::{Transport, TransportKind, frame},
};
use std::{io::{Read, Write}, net::TcpStream};

/// Forwards Bluetooth-framed telegrams over TCP.
/// (Blocking I/O for brevity; use your runtime's async socket in real code.)
struct Tcp(TcpStream);

impl Transport for Tcp {
    fn kind(&self) -> TransportKind {
        TransportKind::Serial
    }

    async fn send(&mut self, telegram: &[u8]) -> Result<(), TransportError> {
        let (buf, len) = frame(telegram)?;
        self.0.write_all(&buf[..len])?;
        Ok(())
    }

    async fn recv(&mut self, buf: &mut [u8; MAX_TELEGRAM_LEN]) -> Result<usize, TransportError> {
        let mut len = [0; 2];
        self.0.read_exact(&mut len)?;
        let len = usize::from(u16::from_le_bytes(len));
        if len > MAX_TELEGRAM_LEN {
            return Err(TransportError::InvalidFrame("too long"));
        }
        self.0.read_exact(&mut buf[..len])?;
        Ok(len)
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
let brick = Brick::new(Tcp(TcpStream::connect("robot.local:5000")?));
Ok(()) }

Things to get right:

  • send writes one telegram and recv returns one. Over Bluetooth-style links, each telegram has a 2-byte little-endian length prefix (frame adds it). USB has no prefix.
  • The returned futures must be Send.
  • After a timeout the Brick drops your future and carries on. A late reply is harmless, because Brick discards replies whose opcode doesn’t match.
  • kind() decides the default timeout and how the transport is reported.
  • Only nxt-rs’s own Usb and Mock transports can carry USB-only commands.

The brick

A Brick<T> is a connection to one NXT. Besides giving access to devices, it has methods for the brick as a whole.

Information

nxt_rs::doc_example(|brick| async move {
let info = brick.info().await?;               // name, Bluetooth address, free flash
let name = brick.name().await?;               // BrickName
let battery = brick.battery().await?;         // Millivolts
let fw = brick.firmware_version().await?;     // FirmwareVersion { protocol, firmware }

println!("{name} [{}], {battery} ({:.1} V)", info.bluetooth_address, battery.volts());
println!("firmware {}, {} bytes of flash free", fw.firmware, info.free_flash);
Ok(()) });

Rename the brick (at most 15 ASCII characters):

nxt_rs::doc_example(|brick| async move {
brick.set_name(&"Robo".parse()?).await?;
Ok(()) });

Six fresh AA batteries read about 9 V. As the voltage drops, the motors get weaker, so check the battery when a robot behaves differently than usual.

Sound

nxt_rs::doc_example(|brick| async move {
use nxt_rs::{protocol::FileName, units::Hz};
use std::time::Duration;

brick.play_tone(Hz::C5, Duration::from_millis(150)).await?;
brick.play_tone(Hz::new(880)?, Duration::from_millis(150)).await?;
brick.files().write(&FileName::new("! Startup.rso")?, b"").await?;
brick.play_sound(&FileName::new("! Startup.rso")?, false).await?;   // a sound file on the brick
brick.stop_sound().await?;
Ok(()) });

play_tone returns immediately while the brick plays the tone. To play a melody, wait for each note’s duration between calls.

Programs

nxt_rs::doc_example(|brick| async move {
use nxt_rs::protocol::FileName;

let program = FileName::new("Demo.rxe")?;
brick.files().write(&program, b"x").await?;
brick.start_program(&program).await?;
assert_eq!(brick.current_program().await?, Some(program));
let was_running = brick.stop_program().await?;   // false if nothing was running
Ok(()) });

See Files and programs for uploading programs, and Messaging for talking to them.

Staying awake

The brick switches off after its sleep timer runs out (configurable in its Settings menu). keep_alive resets the timer and returns its length:

nxt_rs::doc_example(|brick| async move {
let timeout = brick.keep_alive().await?;
println!("the brick sleeps after {timeout:?} without keep-alive");
Ok(()) });

For long programs, spawn brick.keep_alive_task(interval) on your runtime. It sends a keep-alive every interval until an error occurs.

Stopping everything

stop_all coasts all motors, stops sound and unconfigures all sensors. Use it at the end of a program, or in an error handler:

nxt_rs::doc_example(|brick| async move {
brick.stop_all().await?;
Ok(()) });

Timeouts

Each request has a timeout: 1 s by default over USB, 3 s over Bluetooth and serial. To change it, construct the brick yourself:

use std::time::Duration;
use nxt_rs::{Brick, blocking::Block, transport::Usb};

fn main() -> nxt_rs::Result<()> {
let brick = Brick::with_timeout(Usb::first().block()?, Duration::from_secs(5));
Ok(()) }

A timeout returns Error::Timeout. The connection stays usable afterwards.

Clones and sharing

Brick is a cheap handle: clone it freely and send clones to other tasks or threads. All clones share the connection, the port claims and the timeout.

Motors

nxt_rs::doc_example(|brick| async move {
use nxt_rs::prelude::*;

let motor = brick.motor(A)?;
motor.run(power!(75)).degrees(degrees!(360)).wait().await?;
Ok(()) });

A Motor<P, T> controls the motor on output P. Its methods take &self, so a motor can be shared between tasks, for example behind an Arc.

Running

motor.run(power) starts a run command. Configure it with builder methods, then .await it (or .block() it):

MethodEffect
(none)Run until told otherwise
.degrees(d)Stop after turning by d
.wait()Wait until the movement is finished (only after .degrees)
.brake()Brake mode: stop firmly; with .wait(), hold the position afterwards
.regulated()Keep the speed constant under load
.ramp_up() / .ramp_down()Ramp the power smoothly
nxt_rs::doc_example(|brick| async move {
use nxt_rs::prelude::*;
let motor = brick.motor(A)?;

motor.run(power!(50)).await?;                                          // forward, forever
motor.run(power!(-30)).regulated().await?;                             // slow and steady backwards
motor.run(power!(100)).degrees(degrees!(90)).brake().wait().await?;    // quarter turn, then hold
motor.run(power!(60)).degrees(Degrees::from_rotations(3)?).wait().await?;
Ok(()) });

Negative power runs the motor backwards. .degrees always counts the rotation as a positive number.

Why .wait() needs .degrees()

Without a limit the motor never stops by itself, so waiting would hang forever. The run command carries a type parameter that records whether a limit was set, and .wait() only exists on limited runs:

async fn f(brick: nxt_rs::Brick<nxt_rs::transport::Mock>) -> nxt_rs::Result<()> {
use nxt_rs::prelude::*;
let motor = brick.motor(A)?;
motor.run(power!(50)).wait().await?;    // error: no method named `wait` found
Ok(()) }

Stopping

nxt_rs::doc_example(|brick| async move {
use nxt_rs::prelude::*;
let motor = brick.motor(A)?;
motor.coast().await?;   // power off, spin freely
motor.brake().await?;   // stop and actively hold the position
Ok(()) });

Holding the position draws battery power, so coast when you’re done.

Position and state

The firmware keeps three counters per motor, all in degrees:

CounterMeaning
tacho_countThe firmware’s internal counter for the current movement and its limit
block_tacho_countRelative to the last programmed movement
rotation_countSince the last absolute reset; use this for “where is the motor?”
nxt_rs::doc_example(|brick| async move {
use nxt_rs::{prelude::*, motor::Reset};
let motor = brick.motor(A)?;

motor.reset_position(Reset::Absolute).await?;
motor.run(power!(40)).degrees(degrees!(180)).wait().await?;
let tacho = motor.tacho().await?;
println!("now at {}°", tacho.rotation_count);

let running = motor.is_running().await?;
let status = motor.status().await?;         // the full OutputStatus record
Ok(()) });

motor.wait_idle() waits until the firmware reports the motor idle. That’s useful after starting a limited run without .wait().

Driving with two motors

Two motors that drive a robot should turn in step, even if one wheel meets more resistance. A SyncedPair asks the firmware to keep them synchronised:

nxt_rs::doc_example(|brick| async move {
use nxt_rs::{prelude::*, turn_ratio};

let (left, right) = brick.take((B.motor(), C.motor())).await?;
let mut drive = SyncedPair::new(left, right);

drive.forward(power!(70)).degrees(degrees!(720)).wait().await?;              // straight
drive.steer(power!(50), turn_ratio!(25)).degrees(degrees!(360)).wait().await?; // gentle right
drive.steer(power!(50), turn_ratio!(-100)).degrees(degrees!(230)).wait().await?; // spin left
drive.brake().await?;
Ok(()) });

The turn ratio goes from −100 to 100:

RatioMotion
0Straight
25Gentle curve right (right wheel slower)
50Right wheel stopped: pivot around it
100Wheels in opposite directions: spin in place
negativeThe same, to the left

steer and forward support .degrees, .wait and .brake like run.

When you’re done, into_inner() stops the synchronisation and gives the motors back. The firmware keeps trying to sync motors that were synced before, so always use into_inner before driving them separately:

nxt_rs::doc_example(|brick| async move {
use nxt_rs::prelude::*;
let (left, right) = brick.take((B.motor(), C.motor())).await?;
let drive = SyncedPair::new(left, right);
let (left, right) = drive.into_inner().await?;
Ok(()) });

Accuracy

  • Rotation limits are approximate. The firmware switches the motor off when the limit is reached, and the motor overshoots by a few degrees, more at high power. .brake() reduces the overshoot.
  • Each command is a round trip: a few milliseconds over USB, about 60 ms over Bluetooth. For fast closed-loop control (balancing, precise positioning), run the control loop in an on-brick program and talk to it with messages.

Releasing the port

Dropping a motor releases output P for others. motor.release() coasts the motor and gives back the claimed Port, so you can reuse it without letting go. See Ports and devices.

Sensors

Taking a sensor configures its input port on the brick and gives you a typed handle. Every sensor implements the Sensor trait, whose read() returns a reading of a meaningful type:

SensorTake withread() returns
Touchbrick.touch(S1) / S1.touch()bool
Lightbrick.light(S3) / S3.light()Percent
Soundbrick.sound(S2) / S2.sound()Percent
Colour (NXT 2.0), detectingbrick.color(S3) / S3.color()Option<ColorName>
Colour, lamp or ambientcolor.lamp::<Red>(), color.ambient()Percent
Ultrasonicbrick.ultrasonic(S4) / S4.ultrasonic()Option<Centimeters>
Anything analogbrick.raw_sensor(S2, ty, mode)InputValues

Bring the trait into scope (it’s in the prelude) to call read(). Each sensor also has a method with a more specific name, such as is_pressed(), level(), color() or distance().

Touch sensor

nxt_rs::doc_example(|brick| async move {
use nxt_rs::prelude::*;
use std::time::Duration;

let touch = brick.touch(S1).await?;
if touch.read().await? {
    println!("pressed");
}
Ok(()) });

touch.wait_for_press(interval) polls until the button is pressed.

Light sensor

The light sensor has a small red floodlight. With it on (reflected mode) the sensor measures how much light the surface below reflects, which is ideal for following lines. With it off (ambient mode) it measures the surrounding light.

nxt_rs::doc_example(|brick| async move {
use nxt_rs::prelude::*;

let light = brick.light(S3).await?;          // Light<S3, _, Reflected>
let surface = light.read().await?;           // Percent

let light = light.ambient().await?;          // Light<S3, _, Ambient>
let room = light.read().await?;

let light = light.reflected().await?;        // back to reflected
Ok(()) });

The mode is part of the type. Switching modes consumes the sensor and returns a new one, so the type always matches the hardware.

Sound sensor

nxt_rs::doc_example(|brick| async move {
use nxt_rs::prelude::*;

let mic = brick.sound(S2).await?;            // Sound<S2, _, Db>: all frequencies
let loudness = mic.read().await?;
let mic = mic.weighted().await?;             // Sound<S2, _, Dba>: weighted like human hearing
Ok(()) });

Colour sensor (NXT 2.0)

The colour sensor from the NXT 2.0 set needs firmware 1.28 or newer. It has three modes:

Mode typeWhat it doesread()
DetectRecognises black, blue, green, yellow, red and whiteOption<ColorName>
Lamp<Red>, Lamp<Green>, Lamp<Blue>Lights the lamp in one colour and measures the reflectionPercent
AmbientLamp off, measures ambient lightPercent
nxt_rs::doc_example(|brick| async move {
use nxt_rs::{prelude::*, sensor::{Green, Red}};

let color = brick.color(S3).await?;
match color.read().await? {
    Some(ColorName::Red) => println!("stop"),
    Some(ColorName::Green) => println!("go"),
    Some(other) => println!("{other:?}"),
    None => println!("can't tell"),
}

let red = color.lamp::<Red>().await?;
let reflected_red = red.read().await?;
let green = red.lamp::<Green>().await?;
let back = green.detect().await?;
Ok(()) });

Because the reading type depends on the mode, mixing them up doesn’t compile:

async fn f(brick: nxt_rs::Brick<nxt_rs::transport::Mock>) -> nxt_rs::Result<()> {
use nxt_rs::prelude::*;
let color = brick.color(S3).await?;
let level: Percent = color.read().await?;   // error: expected `Percent`, found `Option<ColorName>`
Ok(()) }

Ultrasonic sensor

The ultrasonic sensor measures distances of up to about 2.5 m, in whole centimetres. It returns None when nothing reflects the sound:

nxt_rs::doc_example(|brick| async move {
use nxt_rs::prelude::*;

let sonar = brick.ultrasonic(S4).await?;
match sonar.read().await? {
    Some(d) if d < Centimeters(20) => println!("too close: {d}"),
    Some(d) => println!("{d}"),
    None => println!("nothing in range"),
}
Ok(()) });

By default the sensor measures continuously. ping() switches to single-shot mode and returns up to eight echoes, nearest first, which is useful when several objects are in view:

nxt_rs::doc_example(|brick| async move {
use nxt_rs::prelude::*;
let sonar = brick.ultrasonic(S4).await?;
let echoes = sonar.ping().await?;           // [Option<Centimeters>; 8]
Ok(()) });

The ultrasonic sensor is an I²C device. sonar.bus() gives access to its registers, see I²C devices.

Other analog sensors

Use RawSensor for sensors without a dedicated type, such as the RCX temperature, light and rotation sensors, or your own electronics. Pick a sensor type and mode, and read all values the firmware reports:

nxt_rs::doc_example(|brick| async move {
use nxt_rs::{prelude::*, protocol::{SensorMode, SensorType}};

let temp = brick.raw_sensor(S2, SensorType::Temperature, SensorMode::Celsius).await?;
let values = temp.read().await?;
println!("{} °C (raw {})", f32::from(values.scaled) / 10.0, values.raw);
temp.reconfigure(SensorType::Custom, SensorMode::Raw).await?;
Ok(()) });

InputValues contains:

  • raw: the A/D value,
  • normalized: 0–1023,
  • scaled: depends on the mode,
  • valid: false right after a mode change, until fresh data arrives.

Common methods

Every sensor has:

MethodReturns
port()The InputPort, for the low-level API
brick()The brick
values()The raw InputValues from the firmware
release()Unconfigures the port and returns the claimed Port

Dropping a sensor releases its port without unconfiguring it. Call release() or brick.stop_all() to switch the sensor off.

Timing

After a sensor is configured, the firmware needs a moment before readings are valid. The first reading after taking a sensor may still be stale: check values().await?.valid if it matters. The ultrasonic sensor additionally needs about 100 ms to power up. nxt-rs waits for that automatically.

I²C devices

Digital sensors, such as LEGO’s ultrasonic sensor and most HiTechnic and Mindsensors products, talk I²C (LEGO calls it “low-speed”) over the input port. nxt-rs gives you three levels of access.

A raw bus

I2cBus reads and writes the registers of one device:

nxt_rs::doc_example(|brick| async move {
use nxt_rs::{prelude::*, sensor::I2cAddress};

// LEGO's ultrasonic sensor: address 0x02, needs the 9 V supply.
let bus = brick.i2c_bus(S4, I2cAddress::new(0x02)?, true).await?;

let product = bus.read_string(0x08, 8).await?;   // "LEGO"
let distance = bus.read(0x42, 1).await?[0];
bus.write(0x41, &[0x02]).await?;                 // continuous measurement mode
Ok(()) });
MethodDoes
read(register, len)Reads up to 16 bytes starting at register
read_string(register, len)The same, as a NUL-terminated string
write(register, data)Writes up to 14 bytes starting at register
transfer(tx, rx_len)Sends raw bytes (address included) and reads rx_len back

Addresses use the 8-bit form common in NXT documentation (the 7-bit address shifted left by one), so they’re always even. powered: true switches on the 9 V supply that some sensors need. Transfers retry automatically a few times when the brick reports a bus error.

Typed devices

Wrap a bus in your own type and implement I2cDevice to make a sensor take part in brick.i2c and take, just like the built-in ones:

use nxt_rs::{
    Result,
    port::InputId,
    sensor::{I2cAddress, I2cBus, I2cDevice},
    transport::Transport,
};

/// A HiTechnic compass sensor.
pub struct Compass<P: InputId, T: Transport>(I2cBus<P, T>);

impl<P: InputId, T: Transport> I2cDevice<P, T> for Compass<P, T> {
    const ADDRESS: I2cAddress = match I2cAddress::new(0x02) {
        Ok(a) => a,
        Err(_) => unreachable!(),
    };
    // const NEEDS_9V: bool = false;   (the default)

    fn from_bus(bus: I2cBus<P, T>) -> Self {
        Self(bus)
    }

    fn into_bus(self) -> I2cBus<P, T> {
        self.0
    }
}

impl<P: InputId, T: Transport> Compass<P, T> {
    /// Heading in degrees, 0–359.
    pub async fn heading(&self) -> Result<u16> {
        let b = self.0.read(0x44, 2).await?;
        Ok(u16::from_le_bytes([b[0], b[1]]))
    }
}

nxt_rs::doc_example_with(|brick, sim| async move {
use nxt_rs::prelude::*;
sim.set_i2c_registers(InputPort::S2, 0x02, 0x44, &[90, 0]);
let compass: Compass<_, _> = brick.i2c(S2).await?;
println!("heading {}°", compass.heading().await?);

// or in a group
drop(compass);
let (compass, motor) = brick.take((S2.i2c::<Compass<_, _>>(), A.motor())).await?;
Ok(()) });

I2cDevice::init runs once after the port is configured. Override it to put the device into a known mode (the ultrasonic sensor uses it to select continuous measurement).

Low level

At the protocol level, an I²C transaction is LSWrite, then polling LSGetStatus until the data is ready, then LSRead. brick.direct().ls_transfer(port, tx, rx_len) does all three, retrying while the bus is busy. The individual commands are there too: see Direct commands.

Files and programs

The brick has a small flash file system for programs (.rxe), sounds (.rso), pictures (.ric) and data files. brick.files() gives access to it:

nxt_rs::doc_example(|brick| async move {
use nxt_rs::protocol::FileName;

let files = brick.files();
let name = FileName::new("notes.txt")?;

files.write(&name, b"hello, brick").await?;         // create (fails if it exists)
files.upload(&name, b"replaced").await?;            // create or replace
let data = files.read(&name).await?;                 // Vec<u8>
assert!(files.exists(&name).await?);
files.delete(&name).await?;
Ok(()) });

File names

Names are at most 15 characters, a dot and a 3-character extension, using printable ASCII characters. Spaces are allowed: the firmware’s own sounds are called ! Startup.rso and ! Click.rso. FileName::new checks this. Search patterns may use *, for example *.* or *.rxe.

Listing files

nxt_rs::doc_example(|brick| async move {
use nxt_rs::protocol::FileName;

for file in brick.files().list(&FileName::new("*.*")?).await? {
    println!("{:<20} {:>6} bytes", file.name, file.size);
}
Ok(()) });

find returns the same results as a stream, without collecting them first:

nxt_rs::doc_example(|brick| async move {
use nxt_rs::protocol::FileName;

let mut programs = brick.files().find(&FileName::new("*.rxe")?);
while let Some(program) = programs.next().await {
    println!("{}", program?.name);
}
Ok(()) });

Streaming

For large files, read and write in chunks. A telegram carries at most 58 bytes of file data when reading and 61 when writing, so a 10 KB program takes a couple of hundred requests.

nxt_rs::doc_example(|brick| async move {
use nxt_rs::protocol::FileName;

let name = FileName::new("log.dat")?;
let mut writer = brick.files().create(&name, 1000).await?;   // exact size up front
writer.write_all(&[0u8; 1000]).await?;
writer.close().await?;

let mut reader = brick.files().open(&name).await?;
println!("{} bytes", reader.size());
while let Some(chunk) = reader.read_chunk().await? {
    // chunk derefs to &[u8]
}
Ok(()) });

FileReader implements futures_io::AsyncRead, and FileWriter implements AsyncWrite. In synchronous code, wrap them in nxt_rs::blocking::SyncIo to get std::io::Read / Write:

use std::io::Read;
use nxt_rs::{prelude::*, blocking::SyncIo};

fn main() -> Result<(), Box<dyn std::error::Error>> {
let brick = Brick::mock();
brick.files().write(&FileName::new("notes.txt")?, b"text").block()?;
let mut text = String::new();
SyncIo(brick.files().open(&FileName::new("notes.txt")?).block()?).read_to_string(&mut text)?;
Ok(()) }

File kinds

The brick stores files in three ways:

FileKindUsed forCreated by
FragmentedMost filescreate, write, upload (default)
LinearExecutables: .rxe, .ric, .rtm, .sysChosen automatically for those extensions
DataLogs you append to latercreate_with(name, max_size, FileKind::Data)
nxt_rs::doc_example(|brick| async move {
use nxt_rs::{fs::FileKind, protocol::FileName};

let log = FileName::new("run.log")?;
let mut w = brick.files().create_with(&log, 4096, FileKind::Data).await?;
w.write_all(b"started\n").await?;
w.close().await?;

let mut w = brick.files().append(&log).await?;
w.write_all(b"still running\n").await?;
Ok(()) });

A fragmented or linear file must be written to exactly the size given at creation. A data file can be shorter, and grows by appending.

Handles close themselves

The brick has very few file handles. Readers, writers and searches close theirs when they’re dropped: the close is sent just before the next command. Call close().await explicitly if you want to see errors.

With the low-level system commands, handles are plain numbers and closing them is up to you. To hand a raw handle over to the high-level API, use FileReader::from_handle or FileWriter::from_handle.

Uploading and running programs

Compile NXC programs with NBC (nbc -O=prog.rxe prog.nxc), then:

nxt_rs::doc_example(|brick| async move {
use nxt_rs::protocol::FileName;

let rxe = vec![0u8; 64];
// let rxe = std::fs::read("prog.rxe")?;
let name = FileName::new("prog.rxe")?;
brick.stop_program().await?;
brick.files().upload(&name, &rxe).await?;
brick.start_program(&name).await?;
Ok(()) });

See the upload recipe for a complete tool.

Free space

brick.info().await?.free_flash reports the free flash in bytes. The NXT has about 256 KB of flash in total, part of which holds the firmware.

Messaging

A program running on the brick can exchange short messages with your computer through mailboxes. This is the way to combine fast on-brick control loops with high-level decisions on the computer.

  • The computer writes into inboxes 1–10 of the brick (Mailbox::M1 … Mailbox::M10).
  • The on-brick program answers through response outboxes, one per inbox (numbered 11–20 in NXC). The computer collects replies from there.
  • A message is at most 58 bytes.
nxt_rs::doc_example_with(|brick, sim| async move {
use nxt_rs::protocol::{Mailbox, Message};
use std::time::Duration;

let messages = brick.messages();
messages.send(Mailbox::M1, "forward").await?;

sim.push_outbox(Mailbox::M1, Message::new("ok")?);
if let Some(reply) = messages.recv(Mailbox::M1).await? {
    println!("robot says {:?}", reply.as_str());
}

// or wait for one, polling every 50 ms
sim.push_outbox(Mailbox::M1, Message::new("done")?);
let reply = messages.wait(Mailbox::M1, Duration::from_millis(50)).await?;
Ok(()) });
MethodDoes
send(mailbox, data)Puts text or bytes into the brick’s inbox
recv(mailbox)Takes the next reply from the response outbox, or None
peek(mailbox)Like recv, without removing the message
wait(mailbox, interval)Polls recv until a reply arrives
recv_from(remote, remove)Reads any mailbox, including the brick’s inboxes

The brick can’t push messages to the computer: the computer has to poll. Each mailbox only holds a handful of messages, so poll regularly.

The on-brick side

In NXC, read the computer’s messages with ReceiveRemoteString and answer with SendResponseString. Mailbox constants are zero-based: NXC’s MAILBOX1 is Mailbox::M1.

task main() {
    string cmd;
    while (true) {
        if (ReceiveRemoteString(MAILBOX1, true, cmd) == NO_ERR) {
            if (cmd == "forward") OnFwd(OUT_BC, 75);
            if (cmd == "stop") Off(OUT_BC);
            SendResponseString(MAILBOX1, "ok");
        }
        Wait(10);
    }
}

NXC also has ReceiveRemoteNumber/SendResponseNumber (numbers sent as text) and ReceiveRemoteBool/SendResponseBool. On the Rust side, these are ordinary messages:

nxt_rs::doc_example_with(|brick, sim| async move {
use nxt_rs::protocol::{Mailbox, Message};

brick.messages().send(Mailbox::M2, 42.to_string()).await?;
sim.push_outbox(Mailbox::M2, Message::new("17")?);
let number: i32 = brick.messages().recv(Mailbox::M2).await?
    .and_then(|m| m.as_str()?.parse().ok())
    .unwrap_or_default();
Ok(()) });

See the remote control recipe for a complete example.

The display

The brick’s LCD is 100×64 pixels. You can read what it shows and draw your own text and images on it.

Screenshots

brick.screenshot() captures the screen:

nxt_rs::doc_example(|brick| async move {
let screen = brick.screenshot().await?;

print!("{}", screen.to_text());                       // half-block characters in the terminal
std::fs::write(std::env::temp_dir().join("nxt.pbm"), screen.to_pbm())?;   // a PBM image
let dark = screen.pixel(10, 20);                     // single pixels, (0, 0) is top left
Ok(()) });
  • PBM is a simple image format that most image tools can open. Convert it with e.g. ImageMagick: magick nxt.pbm nxt.png.
  • screen.bytes() returns the raw 800-byte buffer: 8 rows of 100 bytes, each byte a vertical strip of 8 pixels with the least significant bit on top.

Text

brick.show_text(..) clears the screen and prints text from the top left corner. The built-in 5×7 pixel font sits on a 6×8 grid, so the screen fits 8 lines of 16 characters. \n starts a new line:

nxt_rs::doc_example(|brick| async move {
brick.show_text("Hello,\nNXT!").await?;
Ok(()) });

Long lines are cut off, not wrapped. Characters outside printable ASCII are drawn as ?.

Composing a screen

For more control, build a Screen and send it with brick.set_screen(..). draw_text places text at any pixel position; each character cell is drawn opaque, so text stays readable on top of a picture:

nxt_rs::doc_example(|brick| async move {
use nxt_rs::display::{CHAR_HEIGHT, Image, Screen};

let mut screen = Screen::blank();
screen.draw_text(0, 0, "Distance");
screen.draw_text(0, CHAR_HEIGHT, &format!("{:>3} cm", 42));
screen.draw_image(60, 0, &Image::from_fn(40, 40, |x, y| x == y || x + y == 39));
screen.set_pixel(99, 63, true);
brick.set_screen(&screen).await?;
Ok(()) });

Images

An Image is a black and white picture of any size. Draw it with screen.draw_image(x, y, &image), or show it alone with brick.show_image(&image). Pixels past the edge of the screen are cut off. Create one:

  • From a PBM file with Image::from_pbm(..), binary (P4) or plain (P1). Turn any picture into one with ImageMagick: magick logo.png -resize 100x64 -monochrome logo.pbm.
  • From grayscale pixels with Image::from_luma(width, height, &pixels), one byte per pixel, 0 black and 255 white. Pixels darker than 50% gray become dark. For photos, Image::from_luma_dithered(..) keeps shades of gray visible as patterns of dots.
  • From a function with Image::from_fn(width, height, |x, y| ..).
nxt_rs::doc_example(|brick| async move {
use nxt_rs::display::Image;

let logo = Image::from_pbm(b"P1\n5 5\n00100 01110 11111 01110 00100")?;
brick.show_image(&logo).await?;
Ok(()) });

The library decodes no other formats. To show a PNG or JPEG, decode, scale and convert it with an image library such as the image crate, then pass the grayscale pixels on:

let gray = image::open("photo.jpg")?.resize(100, 64, image::imageops::FilterType::Lanczos3).to_luma8();
let photo = Image::from_luma_dithered(gray.width() as usize, gray.height() as usize, gray.as_raw())?;
brick.show_image(&photo).await?;

How it works

Screenshots and drawing read and write the screen buffer in the display module’s I/O map, in about 15 requests. That takes a moment over USB and a second or two over Bluetooth. The layout matches LEGO’s standard firmware. Custom firmware may lay out memory differently.

The brick shows whatever is in the buffer, so a drawing stays until something else draws over it: a running program, or the brick’s menu when you press a button. While the menu is open it also redraws its status bar (the top 8 rows) from time to time. To keep a drawing up, redraw it periodically or keep text out of the top line.

Direct commands

brick.direct() has one method per direct command, the protocol commands for motors, sensors, sound, programs and messages (Bluetooth Developer Kit, appendix 2). The methods are stateless and map 1:1 onto the protocol: no port claims, no hidden state. Each one waits for the brick’s reply and returns it decoded.

nxt_rs::doc_example(|brick| async move {
use nxt_rs::{port::*, protocol::*, units::*};

let d = brick.direct();
d.set_input_mode(InputPort::S1, SensorType::Switch, SensorMode::Boolean).await?;
let pressed = d.get_input_values(InputPort::S1).await?.scaled != 0;
let battery: Millivolts = d.get_battery_level().await?;
Ok(()) });

Commands

OpcodeMethodReply
0x00start_program(&name)()
0x01stop_program()(), or Status::NoActiveProgram
0x02play_sound_file(&name, looping)()
0x03play_tone(hz, duration)()
0x04set_output_state(port_or_all, state)()
0x05set_input_mode(port, sensor_type, mode)()
0x06get_output_state(port)OutputStatus
0x07get_input_values(port)InputValues
0x08reset_input_scaled_value(port)()
0x09message_write(mailbox, message)()
0x0Areset_motor_position(port, relative)()
0x0Bget_battery_level()Millivolts
0x0Cstop_sound_playback()()
0x0Dkeep_alive()Duration (sleep timeout)
0x0Els_get_status(port)u8 bytes ready
0x0Fls_write(port, tx, rx_len)()
0x10ls_read(port)LsBytes
0x11get_current_program_name()FileName
0x13message_read(remote, local, remove)ReceivedMessage
—ls_transfer(port, tx, rx_len)LsBytes: write, poll, read in one call

Motors

set_output_state takes the complete output state. OutputState::COAST and OutputState::BRAKE are ready-made states, and ..OutputState::default() fills in the rest:

nxt_rs::doc_example(|brick| async move {
use nxt_rs::{power, degrees, turn_ratio, port::OutputPort, protocol::*, units::TachoLimit};

let d = brick.direct();

// Motor A: 75 % with speed regulation, stop after one turn.
d.set_output_state(OutputPort::A, OutputState {
    power: power!(75),
    mode: OutputMode::MOTOR_ON | OutputMode::REGULATED,
    regulation: RegulationMode::Speed,
    run_state: RunState::Running,
    tacho_limit: TachoLimit::Degrees(degrees!(360)),
    ..OutputState::default()
}).await?;

// B and C synchronised, curving right.
let synced = OutputState {
    power: power!(60),
    mode: OutputMode::MOTOR_ON | OutputMode::REGULATED,
    regulation: RegulationMode::Sync,
    turn_ratio: turn_ratio!(30),
    run_state: RunState::Running,
    ..OutputState::default()
};
d.set_output_state(OutputPort::B, synced).await?;
d.set_output_state(OutputPort::C, synced).await?;

// Everything off.
d.set_output_state(Outputs::All, OutputState::COAST).await?;
Ok(()) });

Only set_output_state accepts “all outputs” (Outputs::All). The other commands take a single OutputPort, so passing “all” to them doesn’t compile.

Sensors

nxt_rs::doc_example(|brick| async move {
use nxt_rs::{port::InputPort, protocol::*};

let d = brick.direct();
d.set_input_mode(InputPort::S3, SensorType::LightActive, SensorMode::PercentFullScale).await?;
let v = d.get_input_values(InputPort::S3).await?;
println!("valid={} raw={} normalized={} scaled={}", v.valid, v.raw, v.normalized, v.scaled);

// Count presses of a touch sensor.
d.set_input_mode(InputPort::S1, SensorType::Switch, SensorMode::TransitionCount).await?;
d.reset_input_scaled_value(InputPort::S1).await?;
Ok(()) });

See Sensor types and modes for all combinations.

I²C

nxt_rs::doc_example(|brick| async move {
use nxt_rs::{port::InputPort, protocol::*};

let d = brick.direct();
d.set_input_mode(InputPort::S4, SensorType::LowSpeed9V, SensorMode::Raw).await?;

// The whole transaction:
let id = d.ls_transfer(InputPort::S4, &[0x02, 0x08], 4).await?;   // "LEGO"

// Or step by step:
d.ls_write(InputPort::S4, &[0x02, 0x42], 1).await?;
while d.ls_get_status(InputPort::S4).await? < 1 {}
let distance = d.ls_read(InputPort::S4).await?;
Ok(()) });

While a transfer is still running, ls_get_status may fail with Status::PendingCommunication. ls_transfer handles that for you.

Messages, sound, programs

nxt_rs::doc_example(|brick| async move {
use nxt_rs::{protocol::*, units::Hz};
use std::time::Duration;

let d = brick.direct();
d.message_write(Mailbox::M1, Message::new("hi")?).await?;
match d.message_read(RemoteMailbox::Outbox(Mailbox::M1), Mailbox::M1, true).await {
    Ok(reply) => println!("{:?}", reply.message),
    Err(e) if e.status() == Some(nxt_rs::Status::MailboxEmpty) => {}
    Err(e) => return Err(e.into()),
}
d.play_tone(Hz::A4, Duration::from_millis(100)).await?;
let sleep = d.keep_alive().await?;
Ok(()) });

Skipping replies

Each method waits for the brick’s reply. Over Bluetooth, that wait is most of the cost of a command. Commands that only return a status can be sent without asking for a reply. Send the command struct with brick.send_no_reply:

nxt_rs::doc_example(|brick| async move {
use nxt_rs::protocol::{Outputs, OutputState, direct::SetOutputState};

brick.send_no_reply(SetOutputState { port: Outputs::All, state: OutputState::COAST }).await?;
Ok(()) });

Errors on the brick then go unnoticed. Queries can’t be sent this way, because the reply is their whole point. That’s checked at compile time:

async fn f(brick: nxt_rs::Brick<nxt_rs::transport::Mock>) -> nxt_rs::Result<()> {
brick.send_no_reply(nxt_rs::protocol::direct::GetBatteryLevel).await?;   // error
Ok(()) }

System commands

brick.system() has one method per system command, the protocol commands for files, firmware modules, settings and firmware updates (Bluetooth Developer Kit, appendix 1).

Commands

OpcodeMethodReply
0x80open_read(&name)OpenedFile { handle, size }
0x81open_write(&name, size)FileHandle
0x82read(handle, len)Chunk (≤ 58 bytes)
0x83write(handle, data)u16 bytes written (≤ 61 per call)
0x84close(handle)()
0x85delete(&name)()
0x86find_first(&pattern)FoundFile { handle, name, size }
0x87find_next(handle)FoundFile
0x88get_firmware_version()FirmwareVersion
0x89open_write_linear(&name, size)FileHandle
0x8Aopen_read_linear(&name)u32 flash address
0x8Bopen_write_data(&name, size)FileHandle
0x8Copen_append_data(&name)OpenedFile (size = space left)
0x90boot()(), USB only
0x91set_brick_name(&name)()
0x94get_device_info()DeviceInfo
0x97delete_user_flash()(): deletes all files
0x98poll_command_length(buffer)u8
0x99poll(buffer, len)Chunk
0x9Abluetooth_factory_reset()(), USB only
0xA0request_first_module(&pattern)ModuleInfo
0xA1request_next_module(handle)ModuleInfo
0xA2close_module_handle(handle)()
0xA3read_io_map(module, offset, len)Chunk (≤ 55 bytes)
0xA4write_io_map(module, offset, data)u16 (≤ 54 bytes per call)
—read_io_map_all(module, offset, len)Vec<u8>, as many requests as needed
—write_io_map_all(module, offset, data)(), as many requests as needed

Files with raw handles

Unlike the high-level API, nothing is closed for you here. Every handle you open, you close:

nxt_rs::doc_example(|brick| async move {
use nxt_rs::protocol::FileName;

let s = brick.system();
let name = FileName::new("raw.txt")?;

let h = s.open_write(&name, 5).await?;
s.write(h, b"hello").await?;
s.close(h).await?;

let file = s.open_read(&name).await?;
let data = s.read(file.handle, 5).await?;
s.close(file.handle).await?;
assert_eq!(&*data, b"hello");
Ok(()) });

A search handle from find_first must also be closed, unless the search ended with Status::NoMoreFiles.

In Drop implementations, which can’t .await, use brick.close_file_later(handle) or brick.close_module_later(handle). The close is then sent just before the next command. The high-level file types work this way.

Firmware modules and I/O maps

The firmware is made of modules (Display.mod, Output.mod, …). Each one exposes an I/O map, a block of memory you can read and write. That gives access to internals no command covers.

nxt_rs::doc_example(|brick| async move {
use nxt_rs::protocol::{FileName, ModuleId};

let s = brick.system();
let mut module = s.request_first_module(&FileName::new("*.*")?).await?;
loop {
    println!("{:<14} id {:#010x}, I/O map {} bytes", module.name, module.id.0, module.io_map_size);
    match s.request_next_module(module.handle).await {
        Ok(next) => module = next,
        Err(_) => break,
    }
}
s.close_module_handle(module.handle).await?;

// The screen buffer: 800 bytes at offset 119 of the display module.
let screen = s.read_io_map_all(ModuleId::DISPLAY, 119, 800).await?;
Ok(()) });

ModuleId has constants for the modules of LEGO’s standard firmware. Look the IDs up with request_first_module if you run a different firmware.

USB-only commands

boot (reboot into the SAM-BA firmware-update mode) and bluetooth_factory_reset are only accepted over USB. They only exist on a brick whose transport supports them:

async fn f(brick: nxt_rs::Brick<nxt_rs::transport::AnyTransport>) -> nxt_rs::Result<()> {
brick.system().bluetooth_factory_reset().await?;   // error: not available over AnyTransport
Ok(()) }

Brick<Usb>::boot_sam_ba(self) consumes the brick, because the connection is gone afterwards.

Warning: after boot, the brick ticks and does nothing until new firmware is flashed with a tool such as LEGO’s firmware updater or libnxt. Don’t call it by accident.

Custom commands and raw telegrams

Every command is a type

Each protocol command is a struct implementing Command. The trait records the opcode, the reply type and which transports may carry it:

nxt_rs::doc_example(|brick| async move {
use nxt_rs::{port::OutputPort, protocol::direct::{GetBatteryLevel, GetOutputState}};

let millivolts = brick.send(GetBatteryLevel).await?;
let status = brick.send(GetOutputState { port: OutputPort::A }).await?;
Ok(()) });

The methods on direct() and system() are thin wrappers around send.

Defining your own

Enhanced firmware (NBC/NXC “enhanced”, leJOS-style experiments, your own builds) can add opcodes. Command isn’t sealed, so you can describe new commands and get the same typed send:

use nxt_rs::{
    error::{DecodeError, EncodeError},
    protocol::{
        Command, CommandKind, Reply,
        codec::{Decoder, Encoder},
        link::AnyLink,
    },
};

/// A hypothetical command returning the free RAM in bytes.
struct GetFreeMemory;

struct FreeMemory(u32);

impl Reply for FreeMemory {
    fn decode(dec: &mut Decoder<'_>) -> Result<Self, DecodeError> {
        Ok(FreeMemory(dec.u32()?))
    }
}

impl Command for GetFreeMemory {
    const KIND: CommandKind = CommandKind::Direct;
    const OPCODE: u8 = 0x42;
    type Link = AnyLink;           // or UsbLink for USB-only commands
    type Reply = FreeMemory;       // or () for status-only commands

    fn encode(&self, _enc: &mut Encoder<'_>) -> Result<(), EncodeError> {
        Ok(())                     // arguments go here, little-endian
    }
}

async fn f(brick: nxt_rs::Brick<nxt_rs::transport::Usb>) -> nxt_rs::Result<()> {
let free = brick.send(GetFreeMemory).await?.0;
Ok(()) }
  • Encoder writes u8, i8, u16, i32, … little-endian, plus bytes and padded for fixed-width fields.
  • Decoder reads them back and reports truncated replies as DecodeError.
  • A non-zero status in the reply becomes Error::Brick before your decoder runs.
  • If Reply = (), the command can also be sent with send_no_reply.

Encoding without a brick

The protocol layer does no I/O, so you can build and inspect telegrams directly, for example in tests or a packet logger:

use nxt_rs::protocol::{decode_reply, encode_request, direct::{GetBatteryLevel, PlayTone}};
use nxt_rs::units::{Hz, Millis16, Millivolts};

fn main() -> nxt_rs::Result<()> {
let tone = PlayTone { frequency: Hz::new(480)?, duration: Millis16::from_millis(500) };
let mut buf = [0; 64];
let len = encode_request(&tone, false, &mut buf)?;
assert_eq!(&buf[..len], &[0x80, 0x03, 0xE0, 0x01, 0xF4, 0x01]);

let battery = decode_reply::<GetBatteryLevel>(&[0x02, 0x0B, 0x00, 0x4C, 0x1D])?;
assert_eq!(battery, Millivolts(7500));
Ok(()) }

Raw telegrams

At the very bottom, raw_transact sends bytes and returns the raw reply. The status byte isn’t checked:

nxt_rs::doc_example(|brick| async move {
let reply = brick.raw_transact(&[0x00, 0x0B]).await?;   // GetBatteryLevel
assert_eq!(reply.opcode, 0x0B);
assert_eq!(reply.status, 0);
let millivolts = u16::from_le_bytes([reply.data[0], reply.data[1]]);

brick.raw_send(&[0x80, 0x0C]).await?;                   // StopSoundPlayback, no reply
Ok(()) });

The Protocol reference describes the telegram format.

Testing

Robot code is hard to test on a robot. nxt-rs ships two kinds of mock brick so you can test on your laptop and in CI. Both are always available, with no feature flag needed.

Write code against Brick<T>

Make your robot logic generic over the transport, so it runs on both the real and the mock brick:

use nxt_rs::{prelude::*, transport::Transport};

/// Drives forward until the bumper is hit; returns the distance in degrees.
async fn drive_to_wall<T: Transport>(brick: &Brick<T>) -> nxt_rs::Result<i32> {
    let (motor, bumper) = brick.take((A.motor(), S1.touch())).await?;
    let start = motor.tacho().await?.rotation_count;
    while !bumper.read().await? {
        motor.run(power!(50)).degrees(degrees!(30)).wait().await?;
    }
    Ok(motor.tacho().await?.rotation_count - start)
}
fn main() {}

The simulator

Simulator models a brick in memory. Keep a clone to arrange the scene and check the results. A test for the function above:

use nxt_rs::{prelude::*, transport::Transport};
async fn drive_to_wall<T: Transport>(brick: &Brick<T>) -> nxt_rs::Result<i32> {
    let (motor, bumper) = brick.take((A.motor(), S1.touch())).await?;
    let start = motor.tacho().await?.rotation_count;
    while !bumper.read().await? {
        motor.run(power!(50)).degrees(degrees!(30)).wait().await?;
    }
    Ok(motor.tacho().await?.rotation_count - start)
}
use nxt_rs::transport::mock::Simulator;

// #[test]
fn stops_at_wall() {
    let sim = Simulator::new();
    sim.set_sensor(InputPort::S1, 1023, 1);         // the bumper is already pressed
    let brick = Brick::mock_with(sim.clone());

    let distance = drive_to_wall(&brick).block().unwrap();
    assert_eq!(distance, 0);
    assert_eq!(sim.output(OutputPort::A).rotation_count, 0);
    assert_eq!(sim.sensor_config(InputPort::S1).0, nxt_rs::protocol::SensorType::Switch);
}
fn main() { stops_at_wall() }

What the simulator does:

AreaBehaviourControl and inspect
MotorsMovements with a rotation limit finish instantly and update all counters; unlimited runs stay “running”output(port)
Analog sensorsReport the raw and scaled values you setset_sensor(port, raw, scaled), sensor_config(port)
I²CEach address has a 256-byte register file; an ultrasonic sensor appears when a port is set to 9 V low-speedset_i2c_registers, i2c_registers, set_ultrasonic_distance
FilesIn-memory flash with real handle semantics (and a handle limit)add_file, file, file_names, open_handles
MailboxesHost messages land in inboxes; you play the on-brick programinbox(mailbox), push_outbox(mailbox, msg)
Programs, soundStart/stop and tones are recordedprogram(), tones(), sound()
DisplayAn 800-byte screen bufferset_screen(bytes)
BrickName and batteryset_name, set_battery
EverythingEvery request telegram is loggedrequests()

sim.open_handles() is handy for checking that your code doesn’t leak file handles.

Scripts: exact bytes

When you care about the exact telegrams, for example in a custom transport or a custom command, use a Script. It expects each request in order and answers with the reply you give:

use nxt_rs::{Brick, blocking::Block, transport::mock::Script, units::Millivolts};

let script = Script::new()
    .expect([0x00, 0x0B], [0x02, 0x0B, 0x00, 0x4C, 0x1D])   // GetBatteryLevel → 7500 mV
    .expect_no_reply([0x80, 0x0C]);                          // StopSoundPlayback, no reply
let brick = Brick::mock_script(script.clone());

assert_eq!(brick.battery().block().unwrap(), Millivolts(7500));
brick.raw_send(&[0x80, 0x0C]).block().unwrap();
script.assert_done();

An unexpected request makes the call fail with a transport error that shows the expected and actual bytes.

Tests on real hardware

Mark hardware tests #[ignore] and opt in with an environment variable, so they never run by accident:

use nxt_rs::prelude::*;

#[test]
#[ignore = "needs an NXT on USB"]
fn battery_is_fine() {
    if std::env::var_os("NXT_TEST_USB").is_none() { return; }
    let brick = Brick::usb_first().block().unwrap();
    assert!(brick.battery().block().unwrap().0 > 6000);
}
fn main() {}
NXT_TEST_USB=1 cargo test -- --ignored --test-threads=1

--test-threads=1 matters: only one test can open the brick at a time.

Cookbook

Complete programs for common robots. Each one uses the standard NXT driving base:

  • left wheel on output B, right wheel on output C,
  • sensors as noted in each recipe.

All recipes are synchronous for simplicity. Replace .block() with .await to use them in async code.

The examples/ directory of the repository contains more:

ExampleShows
batteryInformation about every brick on USB
drive_squareSynchronised driving
line_followerLight sensor control loop
ultrasonic_avoidAsync program with take
upload_programFile upload
screenshotLCD capture
displayText and images on the LCD
mailbox_chatMessaging
low_levelThe low-level API (runs on the simulator)
simulatorThe high-level API on the simulator
bluetoothBluetooth with tokio (--features bluetooth)
cargo run --example simulator

Line follower

Follows the left edge of a dark line on a light surface, using a light sensor on input 3 pointing at the floor. A touch sensor on input 1 stops the robot.

The robot steers proportionally: the further the reading is from the target, halfway between the line and the floor, the harder it turns.

use nxt_rs::prelude::*;

/// Speed on a straight line.
const BASE: i32 = 35;
/// How hard to steer per percent of error.
const GAIN: i32 = 2;

fn main() -> nxt_rs::Result<()> {
    let brick = Brick::usb_first().block()?;
    let (left, right, light, stop) =
        brick.take((B.motor(), C.motor(), S3.light(), S1.touch())).block()?;

    // Calibrate: place the sensor over the line, then over the floor.
    println!("put the sensor over the line and press enter");
    std::io::stdin().read_line(&mut String::new()).ok();
    let line = i32::from(light.read().block()?.get());
    println!("now over the floor and press enter");
    std::io::stdin().read_line(&mut String::new()).ok();
    let floor = i32::from(light.read().block()?.get());
    let target = (line + floor) / 2;

    while !stop.read().block()? {
        let error = i32::from(light.read().block()?.get()) - target;
        left.run(Power::saturating(BASE + GAIN * error)).block()?;
        right.run(Power::saturating(BASE - GAIN * error)).block()?;
    }

    brick.stop_all().block()?;
    Ok(())
}

Tuning

  • Increase GAIN if the robot loses the line in curves. Decrease it if the robot wobbles on straight lines.
  • Every loop iteration takes three round trips. Over USB that’s fast enough; over Bluetooth, use lower speeds.
  • Power::saturating clamps the computed power to −100…100, so a large error can’t produce an invalid value.

Testing it

Swap Brick::usb_first() for a simulator and feed it light readings with sim.set_sensor(InputPort::S3, raw, percent) to check the steering logic.

Obstacle avoidance

Drives around and turns away from obstacles, using an ultrasonic sensor on input 4 pointing forward. This version is async: it runs on any runtime, here driven by block_on.

use nxt_rs::{blocking::block_on, prelude::*, turn_ratio};

async fn run() -> nxt_rs::Result<()> {
    let brick = Brick::usb_first().await?;
    let (left, right, sonar) = brick.take((B.motor(), C.motor(), S4.ultrasonic())).await?;
    let mut drive = SyncedPair::new(left, right);

    loop {
        match sonar.read().await? {
            Some(distance) if distance < Centimeters(30) => {
                // Back off a little, then spin away.
                drive.forward(power!(-40)).degrees(degrees!(180)).brake().wait().await?;
                drive.steer(power!(50), turn_ratio!(-100)).degrees(degrees!(250)).brake().wait().await?;
            }
            Some(distance) if distance < Centimeters(60) => {
                drive.forward(power!(40)).await?;   // slow down near obstacles
            }
            _ => drive.forward(power!(75)).await?,
        }
    }
}

fn main() -> nxt_rs::Result<()> {
    block_on(run())
}

Notes

  • None means nothing reflected the sound: the way is clear, or the obstacle is soft or angled. Soft objects such as curtains absorb sound.
  • forward(..) without .degrees returns immediately and keeps the motors running, so the loop polls the sensor while driving.
  • Sending the same forward command over and over is harmless.

Remote control with an on-brick program

Commands sent over Bluetooth take about 60 ms each way, which is too slow for tight control. The usual solution is to split the work:

  • an NXC program on the brick runs the fast loop: motors and sensors,
  • your Rust program sends high-level commands and receives status through mailboxes.

On the brick

// remote.nxc — compile with: nbc -O=remote.rxe remote.nxc
task main() {
    string cmd;
    while (true) {
        if (ReceiveRemoteString(MAILBOX1, true, cmd) == NO_ERR) {
            if (cmd == "fwd")   OnFwdSync(OUT_BC, 75, 0);
            if (cmd == "back")  OnRevSync(OUT_BC, 75, 0);
            if (cmd == "left")  OnFwdSync(OUT_BC, 50, -100);
            if (cmd == "right") OnFwdSync(OUT_BC, 50, 100);
            if (cmd == "stop")  Off(OUT_BC);
            SendResponseString(MAILBOX1, cmd);
        }
        if (SensorUS(IN_4) < 20) {
            Off(OUT_BC);
            SendResponseString(MAILBOX2, "obstacle");
        }
        Wait(10);
    }
}

The program needs the ultrasonic sensor configured on port 4. Add SetSensorLowspeed(IN_4); at the start of main in real code.

On the computer

use std::{io::BufRead, time::Duration};

use nxt_rs::prelude::*;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let brick = Brick::usb_first().block()?;   // or Brick::bluetooth(..)

    // Upload and start the program (see the upload recipe).
    let program = FileName::new("remote.rxe")?;
    brick.stop_program().block()?;
    brick.files().upload(&program, &std::fs::read("remote.rxe")?).block()?;
    brick.start_program(&program).block()?;

    let messages = brick.messages();
    println!("commands: fwd, back, left, right, stop");
    for line in std::io::stdin().lock().lines() {
        let cmd = line?;
        messages.send(Mailbox::M1, cmd.trim()).block()?;
        let ack = messages.wait(Mailbox::M1, Duration::from_millis(20)).block()?;
        println!("brick: {}", ack.as_str().unwrap_or("?"));

        if let Some(alert) = messages.recv(Mailbox::M2).block()? {
            println!("brick: {}", alert.as_str().unwrap_or("?"));
        }
    }
    brick.stop_program().block()?;
    Ok(())
}

Why this works well

  • The brick reacts to obstacles within 10 ms, whatever the Bluetooth latency.
  • The computer only sends a command when something changes.
  • Mailbox 1 carries commands and acknowledgements; mailbox 2 carries events. Use separate mailboxes for separate streams.

Uploading and running programs

A small command-line tool that uploads compiled programs (.rxe), sounds (.rso) or any other file, and optionally runs it.

use std::path::Path;

use nxt_rs::prelude::*;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut args = std::env::args().skip(1);
    let path = args.next().ok_or("usage: upload <file> [--run]")?;
    let run = args.any(|a| a == "--run");

    let data = std::fs::read(&path)?;
    let base = Path::new(&path).file_name().and_then(|n| n.to_str()).ok_or("bad file name")?;
    let name = FileName::new(base)?;    // checks the 15.3 limit

    let brick = Brick::usb_first().block()?;
    let free = brick.info().block()?.free_flash;
    if data.len() as u64 > u64::from(free) {
        return Err(format!("{name} needs {} bytes, only {free} free", data.len()).into());
    }

    brick.stop_program().block()?;               // a running program may hold the file
    brick.files().upload(&name, &data).block()?; // replaces an existing file
    println!("uploaded {name} ({} bytes)", data.len());

    if run {
        match name.extension() {
            Some("rxe") => brick.start_program(&name).block()?,
            Some("rso") => brick.play_sound(&name, false).block()?,
            _ => println!("don't know how to run {name}"),
        }
    }
    Ok(())
}

Notes

  • Executables are written as linear files automatically, as the brick requires.
  • Uploading over Bluetooth is slow: each request carries at most 61 bytes. Prefer USB for large files.
  • brick.files().list(&"*.*".parse()?) shows what’s already on the brick.

Troubleshooting

Connecting

no NXT brick found over USB

  • Is the brick switched on? It doesn’t show up on USB while it’s off.
  • On Linux, is the udev rule installed (NixOS, other distributions), and did you replug the brick afterwards? Without a rule, the program has to run as root.
  • On Windows, is the WinUSB driver installed?
  • If the brick ticks and its screen stays empty, it’s in firmware-update mode. Flash the firmware with LEGO’s tools first.

Permission denied (Linux)

The udev rule is missing or hasn’t taken effect. Replug the brick, or log out and in again if you just joined the plugdev group. As a quick workaround, run the program with sudo (details).

The device is busy

Another program (NXT-G, BricxCC, another nxt-rs program, a test running in parallel) has the brick open. Close it. Run hardware tests with --test-threads=1.

Bluetooth: connection refused or times out

  • Is the brick paired and trusted (bluetoothctl info <address>)?
  • Is Bluetooth switched on in the brick’s menu?
  • The brick accepts only one connection from a computer at a time.
  • Restarting Bluetooth on the brick (Bluetooth → On/Off) often helps.

While running

Error::Timeout

The brick didn’t answer in time. Over Bluetooth, or during heavy file transfers, raise the timeout with Brick::with_timeout. After a timeout the connection stays usable, and a late reply is ignored.

The brick switches itself off

It has a sleep timer. Spawn brick.keep_alive_task(interval), or call brick.keep_alive() regularly.

Error::PortTaken

Another device already claims the port. That can be one you still hold, or a clone of the brick. Drop it first, or call release() to reuse its port. brick.port_claims().claimed() shows what’s in use.

Status::ChannelBusy or Status::BusError from an I²C sensor

The sensor isn’t connected, isn’t powered, or the port isn’t configured for I²C. The high-level sensor types configure the port and power the sensor for you. With the low-level API, set the port to SensorType::LowSpeed9V (ultrasonic) or LowSpeed first, and give the sensor about 100 ms to start.

The colour sensor reads nonsense

It needs firmware 1.28 or newer (brick.firmware_version()).

Motors overshoot the requested angle

That’s the firmware: it switches the motor off once the angle is reached. Use .brake(), lower power, or a closed loop on the brick.

Synchronised motors behave oddly after a SyncedPair

Use drive.into_inner().await? to get the motors back. It ends the synchronisation properly.

Status::FileExists when writing a file

files().write never overwrites. Use files().upload, which replaces the file.

Status::NoMoreHandles

Too many files or searches are open. Drop readers and writers you no longer need. With the low-level API, close every handle.

Debugging

With the default tracing feature, every telegram is logged at TRACE level under the target nxt_rs::telegram:

// with tracing-subscriber:
// tracing_subscriber::fmt().with_env_filter("nxt_rs=trace").init();
TRACE nxt_rs::telegram: -> [00, 0b]
TRACE nxt_rs::telegram: <- [02, 0b, 00, 4c, 1d]

Compare these with the protocol reference.

Cargo features

FeatureDefaultEnablesExtra dependencies
usb✓transport::Usb, Brick::usb_first, Brick::usb_all, Brick::boot_sam_banusb
tracing✓Telegram logging at TRACE leveltracing
bluetoothtransport::Bluetooth, Brick::bluetooth (Linux only)bluer, tokio; system: D-Bus, pkg-config
serialtransport::Serial, Brick::serialserialport, blocking

Always available, without features: the protocol layer, the low- and high-level APIs, AnyTransport, and the mock transports (Simulator, Script).

Turning off defaults gives a build with no system dependencies, for example for simulation-only tests or a custom transport:

[dependencies]
nxt-rs = { git = "https://forge.wvffle.net/wvffle/nxt-rs.git", default-features = false }

Protocol

The NXT firmware speaks a simple request/reply protocol, documented by LEGO in the MINDSTORMS NXT Bluetooth Developer Kit. nxt-rs implements it in nxt_rs::protocol, without any I/O.

Telegrams

request:  [type] [opcode] [arguments…]
reply:    [0x02] [opcode] [status] [data…]
Type byteMeaning
0x00Direct command, reply requested
0x01System command, reply requested
0x80Direct command, no reply
0x81System command, no reply
0x02Reply
  • All multi-byte values are little-endian.
  • A telegram is at most 64 bytes.
  • Status 0x00 means success. Other values are listed in Status codes.
  • File and module names are 20-byte, NUL-padded ASCII fields (15.3 characters plus the terminator).

Framing

LinkFraming
USBNone: one bulk transfer per telegram (endpoint 0x01 out, 0x82 in)
Bluetooth / serialEach telegram is preceded by its length as a 2-byte little-endian number
use nxt_rs::transport::frame;

let (buf, len) = frame(&[0x00, 0x0B]).unwrap();
assert_eq!(&buf[..len], &[0x02, 0x00, 0x00, 0x0B]);

Example: PlayTone

80 03 E0 01 F4 01
│  │  └─┬─┘ └─┬─┘
│  │    │     └─ duration 500 ms (0x01F4)
│  │    └─────── frequency 480 Hz (0x01E0)
│  └──────────── opcode 0x03 PlayTone
└─────────────── direct command, no reply

Example: GetOutputState reply

02 06 00 | 01 | 9C | 05 | 01 | 00 | 20 | 68 01 00 00 | 10 00 00 00 | F0 FF FF FF | 00 01 00 00
reply      port B    mode     ratio      tacho limit   tacho count   block tacho   rotation
   opcode     power -100  regulation  run state 0x20   360           16            -16           256
      status ok

Commands

The opcode tables are in Direct commands and System commands. Each command’s struct in nxt_rs::protocol::direct and nxt_rs::protocol::system documents its fields.

Output fields

FieldValues
Port0–2 (A–C); 0xFF = all (only SetOutputState)
Power-100…100 (signed byte)
Mode bits0x01 motor on, 0x02 brake, 0x04 regulated
Regulation0x00 idle, 0x01 speed, 0x02 sync
Turn ratio-100…100
Run state0x00 idle, 0x10 ramp up, 0x20 running, 0x40 ramp down
Tacho limitu32 degrees; 0 = unlimited

Timing

  • USB: a few milliseconds per round trip.
  • Bluetooth: about 60 ms per round trip. The Bluetooth module needs time to switch between sending and receiving. Commands sent without a reply avoid half of that.

Status codes

A non-zero status byte in a reply becomes Error::Brick { status, .. }, with status a nxt_rs::Status. Status::from_code and Status::code convert between the two forms.

Direct commands

CodeStatusMeaning
0x20PendingCommunicationTransaction still in progress (I²C); retry
0x40MailboxEmptyThe mailbox has no message
0xBDRequestFailedRequest failed, e.g. the file was not found
0xBEUnknownOpcodeUnknown command opcode
0xBFInsanePacketMalformed telegram
0xC0OutOfRangeData contains out-of-range values
0xDDBusErrorCommunication bus error (no I²C answer)
0xDECommBufferFullNo free memory in the communication buffer
0xDFInvalidChannelChannel/connection not valid
0xE0ChannelBusyChannel/connection not configured or busy
0xECNoActiveProgramNo program is running
0xEDIllegalSizeIllegal size
0xEEIllegalMailboxIllegal mailbox number
0xEFInvalidFieldInvalid field of a structure
0xF0BadInputOutputBad input or output port
0xFBInsufficientMemoryNot enough memory
0xFFBadArgumentsBad arguments

System commands

CodeStatusMeaning
0x81NoMoreHandlesNo more handles
0x82NoSpaceNo space
0x83NoMoreFilesNo more files (end of a search)
0x84EndOfFileExpectedEnd of file expected
0x85EndOfFileEnd of file
0x86NotALinearFileNot a linear file
0x87FileNotFoundFile not found
0x88HandleAlreadyClosedHandle already closed
0x89NoLinearSpaceNo contiguous space for a linear file
0x8AUndefinedUndefined error
0x8BFileBusyFile is busy
0x8CNoWriteBuffersNo write buffers
0x8DAppendNotPossibleAppend not possible
0x8EFileFullFile is full
0x8FFileExistsFile exists
0x90ModuleNotFoundModule not found
0x91OutOfBoundaryOut of boundary
0x92IllegalFileNameIllegal file name
0x93IllegalHandleIllegal handle

Any other code becomes Status::Unknown(code).

Status::is_retryable() is true for PendingCommunication, CommBufferFull, ChannelBusy and BusError.

Sensor types and modes

SetInputMode configures an input port with a type (which electrical interface and which sensor) and a mode (how the firmware converts the reading into the scaled value). The high-level sensor types pick these for you. With RawSensor and the low-level API you choose them yourself.

Types

SensorTypeCodeSensorHigh-level type
NoSensor0x00Nothing—
Switch0x01Touch sensor (NXT or RCX)Touch
Temperature0x02RCX temperatureRawSensor
Reflection0x03RCX lightRawSensor
Angle0x04RCX rotationRawSensor
LightActive0x05NXT light, floodlight onLight<_, _, Reflected>
LightInactive0x06NXT light, floodlight offLight<_, _, Ambient>
SoundDb0x07NXT sound, dBSound<_, _, Db>
SoundDba0x08NXT sound, dBASound<_, _, Dba>
Custom0x09Custom analogRawSensor
LowSpeed0x0AI²C, no 9 VI2cBus, I2cDevice
LowSpeed9V0x0BI²C with 9 V supplyUltrasonic, I2cBus
HighSpeed0x0CRS-485 (port 4)—
ColorFull0x0DNXT 2.0 colour, detectionColor<_, _, Detect>
ColorRed0x0EColour sensor, red lampColor<_, _, Lamp<Red>>
ColorGreen0x0FColour sensor, green lampColor<_, _, Lamp<Green>>
ColorBlue0x10Colour sensor, blue lampColor<_, _, Lamp<Blue>>
ColorNone0x11Colour sensor, lamp offColor<_, _, Ambient>

Types 0x0C and up need NXT 2.0 firmware (1.28 or newer).

Modes

SensorModeCodescaled value
Raw0x00Raw A/D value, 0–1023
Boolean0x200 or 1
TransitionCount0x40Number of 0↔1 transitions
PeriodCount0x60Number of full 0→1→0 periods
PercentFullScale0x800–100
Celsius0xA0Tenths of a degree Celsius (RCX temperature)
Fahrenheit0xC0Tenths of a degree Fahrenheit
AngleSteps0xE0RCX rotation steps (16 per turn)

The low five bits of the mode byte are the slope used for boolean conversion (0 uses fixed thresholds):

use nxt_rs::protocol::{SensorMode, Slope};

let mode = SensorMode::Boolean.with_slope(Slope::new(10).unwrap());
assert_eq!(mode.to_wire(), 0x2A);

Colour numbers

In ColorFull mode, scaled holds the detected colour:

ValueColorName
1Black
2Blue
3Green
4Yellow
5Red
6White

Ultrasonic sensor registers

The LEGO ultrasonic sensor is an I²C device at address 0x02:

RegisterContent
0x00Version (8 bytes, e.g. V1.0)
0x08Product ID (LEGO)
0x10Sensor type (Sonar)
0x41Command: 0 off, 1 single shot, 2 continuous, 3 event capture, 4 warm reset
0x42–0x49Measurements 0–7 in cm (255 = no echo)

Limits

WhatLimitWhere it matters
Telegram64 bytesEverything
File name15 + . + 3 ASCII charactersFileName
Brick name15 ASCII charactersBrickName
Message58 bytesMessage, messages().send
Mailboxes10 inboxes, 10 response outboxesMailbox, RemoteMailbox
File read chunk58 bytes per requestsystem().read, MAX_READ
File write chunk61 bytes per requestsystem().write, MAX_WRITE
I/O-map read chunk55 bytes per requestMAX_IO_MAP_READ
I/O-map write chunk54 bytes per requestMAX_IO_MAP_WRITE
Poll chunk59 bytesMAX_POLL
I²C transfer16 bytes each wayLsBytes
Motor power, turn ratio−100 … 100Power, TurnRatio
Tone200 … 14 000 Hz, ≤ 65.535 sHz, Millis16
Screen100 × 64 pixels, 800 bytesdisplay::Screen
Outputs / inputs3 / 4OutputPort, InputPort

The chunk constants live in nxt_rs::protocol::system.

Sources

nxt-rs implements the protocol described in LEGO’s MINDSTORMS NXT developer kits. LEGO published them on its MINDSTORMS Advanced Users — Developer Kits page, and they are mirrored widely.

DocumentUsed for
Bluetooth Developer Kit, Appendix 1: Communication protocolSystem commands, system status codes, telegram format
Bluetooth Developer Kit, Appendix 2: Direct commandsDirect commands, direct status codes, sensor types and modes, output modes
Bluetooth Developer Kit, Appendix 3: ARM7 Bluetooth interfaceBluetooth framing
Hardware Developer Kit, ultrasonic sensor I²C appendixUltrasonic registers
NXT firmware source (c_display.iom)Screen buffer layout and offset

When working on the protocol layer, keep the PDFs in docs/reference/. That directory is excluded from the published crate. The golden-byte tests in tests/protocol.rs encode the documents’ examples and field layouts.

Other NXT libraries were useful for cross-checking behaviour:

  • nxt-python, especially for sensor timing and motor quirks,
  • the nxt crate from bricks-rs,
  • the OCaml mindstorm library, whose typed connections inspired the USB-only command checks.

Changelog

All notable changes to nxt-rs. The project follows semantic versioning.

0.2.0

Added

  • Drawing on the display. See The display.
    • Brick::show_text clears the LCD and prints text: 8 lines of 16 characters in a built-in 5×7 pixel font.
    • Brick::show_image clears the LCD and shows an image.
    • Brick::set_screen writes a whole Screen to the LCD, and Brick::clear_screen blanks it.
    • Screen can now be edited: Screen::blank, Screen::from_text, set_pixel, clear, draw_text and draw_image.
    • New display::Image type, a black and white picture of any size, built from a PBM file (Image::from_pbm, P1 and P4), 8-bit grayscale pixels (Image::from_luma, or Image::from_luma_dithered for photos) or a function (Image::from_fn).
    • New constants CHAR_WIDTH, CHAR_HEIGHT, TEXT_COLUMNS and TEXT_LINES describe the text grid.
  • A display example that draws text and an image (or a PBM file given on the command line).

0.1.0

The first release: both API tiers (every Direct and System command, and the high-level motors, sensors, files, messaging and screenshots), USB, Bluetooth and serial transports, and the simulated brick.

Contributing

Development environment

The repository has a devenv setup with Rust, D-Bus, pkg-config and mdBook:

devenv shell

Without devenv, install a Rust toolchain (1.85+), plus libdbus-1-dev and pkg-config if you build the bluetooth feature.

Checks

cargo fmt --check
cargo clippy --all-features --all-targets -- -D warnings
cargo test --all-features
RUSTDOCFLAGS="-D warnings" cargo doc --all-features --no-deps

The test suite contains:

SuiteWhat
doctestsEvery example in the API docs and in this book runs against the simulator
tests/protocol.rsByte-exact encoding and decoding of every command
tests/proptest.rsProperty tests: decoders never panic, value ranges
tests/high_level.rsThe high-level API against the simulator
tests/compile_fail.rsThings that must not compile (tests/ui/*.rs)
tests/tiers.rsThe high-level API uses only the public low-level API
tests/hardware.rsReal-brick tests, ignored by default

When a compile-fail case’s error message changes, regenerate the expected output and review the diff:

TRYBUILD=overwrite cargo test --all-features --test compile_fail

Hardware tests need a brick on USB with nothing attached to output A:

NXT_TEST_USB=1 cargo test --all-features --test hardware -- --ignored --test-threads=1

This book

The book lives in docs/ and is built with mdBook (included in the devenv shell). Pushes to master that touch docs/ publish it to https://forge-pages.wvffle.net/wvffle/nxt-rs/ (.forgejo/workflows/book.yml).

mdbook serve docs        # http://localhost:3000
mdbook build docs        # into docs/book/

Every Rust code block is compiled and run as a doctest (see the book module at the end of src/lib.rs). Lines starting with # are hidden in the rendered book but still compiled. Use them for setup:

```rust
# nxt_rs::doc_example(|brick| async move {
let battery = brick.battery().await?;
# Ok(()) });
```

doc_example runs the closure against a simulated brick, and doc_example_with also passes the Simulator. Use rust,no_run for code that needs real hardware, and rust,compile_fail for code that must not compile. When you add a page, add it to SUMMARY.md and to the pages! list in src/lib.rs. A test checks that both stay in sync.

Design

PLAN.md describes the architecture and the reasoning behind it: the two tiers, port claims, the sans-I/O protocol layer and the transports.