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 aPercent, the ultrasonic sensorOption<Centimeters>, and the colour sensor aColorName. - Async or not, your choice. Everything is
asyncand 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 newto 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:
| Distribution | Packages |
|---|---|
| Debian, Ubuntu | libdbus-1-dev pkg-config |
| Fedora | dbus-devel pkgconf-pkg-config |
| Arch | dbus 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.
| Link | Code | Feature |
|---|---|---|
| USB, first brick found | Brick::usb_first().await? | usb (default) |
| USB, every brick | Brick::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 available | nxt_rs::connect_any().await? | — |
| Simulated brick | Brick::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
100spins 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: run this program in a test.
- Motors and Sensors: everything the devices can do.
- Cookbook: complete robots.
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>orColorName. - 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()andbrick.system()have one method per protocol command, with typed arguments and decoded replies.brick.send(command)sends anyCommand, 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 → to | How |
|---|---|
| high → low | motor.port(), sensor.port() give the runtime port; motor.brick() gives the brick; reader.handle() gives the raw file handle |
| low → high | brick.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:
| Port | Value / type | Low-level enum |
|---|---|---|
| Output A, B, C | A, B, C | OutputPort::A, … |
| Input 1–4 | S1, S2, S3, S4 | InputPort::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(()) });
| Device | One at a time | Spec for take |
|---|---|---|
| Motor | brick.motor(B)? | B.motor() |
| Touch sensor | brick.touch(S1).await? | S1.touch() |
| Light sensor | brick.light(S3).await? | S3.light() |
| Sound sensor | brick.sound(S2).await? | S2.sound() |
| Colour sensor | brick.color(S3).await? | S3.color() |
| Ultrasonic sensor | brick.ultrasonic(S4).await? | S4.ultrasonic() |
| Any analog sensor | brick.raw_sensor(S2, ty, mode).await? | S2.raw(ty, mode) |
I²C device D | brick.i2c::<D, _>(S4).await? | S4.i2c::<D>() |
| Raw I²C bus | brick.i2c_bus(S4, address, powered).await? | — |
| Bare port | brick.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
| Type | Range | Notes |
|---|---|---|
Power | −100 … 100 | power!, Power::new, Power::saturating, .reverse(), -p |
TurnRatio | −100 … 100 | turn_ratio!; 0 straight, ±50 one wheel stopped, ±100 spin |
Degrees | 1 … u32::MAX | degrees!, Degrees::from_rotations |
TachoLimit | Unlimited or Degrees(d) | “Run forever” is its own variant, never 0 |
Hz | 200 … 14 000 | Note constants Hz::C4 … Hz::C5 |
Millis16 | 0 … 65 535 ms | From Duration with try_from |
Percent | 0 … 100 | Light and sound readings |
Centimeters | 0 … 254 | Ultrasonic distance |
Millivolts | any u16 | Battery voltage; .volts() |
Protocol strings and payloads work the same way:
| Type | Rule |
|---|---|
FileName | printable ASCII, at most 15 characters + . + 3 (* allowed for searches) |
BrickName | ASCII, 1–15 characters |
Message | at most 58 bytes |
LsBytes | at most 16 bytes (one I²C transfer) |
Mailbox | M1 … M10 |
I2cAddress | even (8-bit form) |
Slope | 0 … 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>.
| Variant | Meaning |
|---|---|
Error::Transport(e) | The USB/Bluetooth/serial link failed: unplugged, permission denied, … |
Error::Timeout | The 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 level | High level |
|---|---|
stop_program() fails with NoActiveProgram | brick.stop_program() returns Ok(false) |
get_current_program_name() fails with NoActiveProgram | brick.current_program() returns Ok(None) |
message_read() fails with MailboxEmpty | messages().recv() returns Ok(None) |
find_first() fails with FileNotFound | files().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, product0x0002, with bulk endpoints0x01(out) and0x82(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.
bluerruns on tokio. Inside#[tokio::main]everything works..block()starts a shared tokio runtime for you when thebluetoothfeature 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_replywhere 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
bluetoothfeature 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
| OS | Steps | Port |
|---|---|---|
| Linux | Pair (see Bluetooth), then sudo rfcomm bind 0 00:16:53:0A:0B:0C 1 | /dev/rfcomm0 |
| macOS | Pair in System Settings → Bluetooth | /dev/tty.NXT-DevB (or similar, see ls /dev/tty.*) |
| Windows | Pair in Settings → Bluetooth, then More Bluetooth settings → COM Ports | The 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
Disconnectedfrom 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:
sendwrites one telegram andrecvreturns one. Over Bluetooth-style links, each telegram has a 2-byte little-endian length prefix (frameadds it). USB has no prefix.- The returned futures must be
Send. - After a timeout the
Brickdrops your future and carries on. A late reply is harmless, becauseBrickdiscards replies whose opcode doesn’t match. kind()decides the default timeout and how the transport is reported.- Only nxt-rs’s own
UsbandMocktransports 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):
| Method | Effect |
|---|---|
| (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:
| Counter | Meaning |
|---|---|
tacho_count | The firmware’s internal counter for the current movement and its limit |
block_tacho_count | Relative to the last programmed movement |
rotation_count | Since 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:
| Ratio | Motion |
|---|---|
0 | Straight |
25 | Gentle curve right (right wheel slower) |
50 | Right wheel stopped: pivot around it |
100 | Wheels in opposite directions: spin in place |
| negative | The 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:
| Sensor | Take with | read() returns |
|---|---|---|
| Touch | brick.touch(S1) / S1.touch() | bool |
| Light | brick.light(S3) / S3.light() | Percent |
| Sound | brick.sound(S2) / S2.sound() | Percent |
| Colour (NXT 2.0), detecting | brick.color(S3) / S3.color() | Option<ColorName> |
| Colour, lamp or ambient | color.lamp::<Red>(), color.ambient() | Percent |
| Ultrasonic | brick.ultrasonic(S4) / S4.ultrasonic() | Option<Centimeters> |
| Anything analog | brick.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 type | What it does | read() |
|---|---|---|
Detect | Recognises black, blue, green, yellow, red and white | Option<ColorName> |
Lamp<Red>, Lamp<Green>, Lamp<Blue> | Lights the lamp in one colour and measures the reflection | Percent |
Ambient | Lamp off, measures ambient light | Percent |
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:falseright after a mode change, until fresh data arrives.
Common methods
Every sensor has:
| Method | Returns |
|---|---|
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(()) });
| Method | Does |
|---|---|
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:
FileKind | Used for | Created by |
|---|---|---|
Fragmented | Most files | create, write, upload (default) |
Linear | Executables: .rxe, .ric, .rtm, .sys | Chosen automatically for those extensions |
Data | Logs you append to later | create_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(()) });
| Method | Does |
|---|---|
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
| Opcode | Method | Reply |
|---|---|---|
0x00 | start_program(&name) | () |
0x01 | stop_program() | (), or Status::NoActiveProgram |
0x02 | play_sound_file(&name, looping) | () |
0x03 | play_tone(hz, duration) | () |
0x04 | set_output_state(port_or_all, state) | () |
0x05 | set_input_mode(port, sensor_type, mode) | () |
0x06 | get_output_state(port) | OutputStatus |
0x07 | get_input_values(port) | InputValues |
0x08 | reset_input_scaled_value(port) | () |
0x09 | message_write(mailbox, message) | () |
0x0A | reset_motor_position(port, relative) | () |
0x0B | get_battery_level() | Millivolts |
0x0C | stop_sound_playback() | () |
0x0D | keep_alive() | Duration (sleep timeout) |
0x0E | ls_get_status(port) | u8 bytes ready |
0x0F | ls_write(port, tx, rx_len) | () |
0x10 | ls_read(port) | LsBytes |
0x11 | get_current_program_name() | FileName |
0x13 | message_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
| Opcode | Method | Reply |
|---|---|---|
0x80 | open_read(&name) | OpenedFile { handle, size } |
0x81 | open_write(&name, size) | FileHandle |
0x82 | read(handle, len) | Chunk (≤ 58 bytes) |
0x83 | write(handle, data) | u16 bytes written (≤ 61 per call) |
0x84 | close(handle) | () |
0x85 | delete(&name) | () |
0x86 | find_first(&pattern) | FoundFile { handle, name, size } |
0x87 | find_next(handle) | FoundFile |
0x88 | get_firmware_version() | FirmwareVersion |
0x89 | open_write_linear(&name, size) | FileHandle |
0x8A | open_read_linear(&name) | u32 flash address |
0x8B | open_write_data(&name, size) | FileHandle |
0x8C | open_append_data(&name) | OpenedFile (size = space left) |
0x90 | boot() | (), USB only |
0x91 | set_brick_name(&name) | () |
0x94 | get_device_info() | DeviceInfo |
0x97 | delete_user_flash() | (): deletes all files |
0x98 | poll_command_length(buffer) | u8 |
0x99 | poll(buffer, len) | Chunk |
0x9A | bluetooth_factory_reset() | (), USB only |
0xA0 | request_first_module(&pattern) | ModuleInfo |
0xA1 | request_next_module(handle) | ModuleInfo |
0xA2 | close_module_handle(handle) | () |
0xA3 | read_io_map(module, offset, len) | Chunk (≤ 55 bytes) |
0xA4 | write_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 orlibnxt. 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(()) }
Encoderwritesu8,i8,u16,i32, … little-endian, plusbytesandpaddedfor fixed-width fields.Decoderreads them back and reports truncated replies asDecodeError.- A non-zero status in the reply becomes
Error::Brickbefore your decoder runs. - If
Reply = (), the command can also be sent withsend_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:
| Area | Behaviour | Control and inspect |
|---|---|---|
| Motors | Movements with a rotation limit finish instantly and update all counters; unlimited runs stay “running” | output(port) |
| Analog sensors | Report the raw and scaled values you set | set_sensor(port, raw, scaled), sensor_config(port) |
| I²C | Each address has a 256-byte register file; an ultrasonic sensor appears when a port is set to 9 V low-speed | set_i2c_registers, i2c_registers, set_ultrasonic_distance |
| Files | In-memory flash with real handle semantics (and a handle limit) | add_file, file, file_names, open_handles |
| Mailboxes | Host messages land in inboxes; you play the on-brick program | inbox(mailbox), push_outbox(mailbox, msg) |
| Programs, sound | Start/stop and tones are recorded | program(), tones(), sound() |
| Display | An 800-byte screen buffer | set_screen(bytes) |
| Brick | Name and battery | set_name, set_battery |
| Everything | Every request telegram is logged | requests() |
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.
- Line follower: light sensor, proportional control
- Obstacle avoidance: ultrasonic sensor, synchronised driving
- Remote control with an on-brick program: messaging with NXC
- Uploading and running programs: a small command-line tool
The examples/ directory of the repository contains more:
| Example | Shows |
|---|---|
battery | Information about every brick on USB |
drive_square | Synchronised driving |
line_follower | Light sensor control loop |
ultrasonic_avoid | Async program with take |
upload_program | File upload |
screenshot | LCD capture |
display | Text and images on the LCD |
mailbox_chat | Messaging |
low_level | The low-level API (runs on the simulator) |
simulator | The high-level API on the simulator |
bluetooth | Bluetooth 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
GAINif 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::saturatingclamps 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
Nonemeans nothing reflected the sound: the way is clear, or the obstacle is soft or angled. Soft objects such as curtains absorb sound.forward(..)without.degreesreturns immediately and keeps the motors running, so the loop polls the sensor while driving.- Sending the same
forwardcommand 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
| Feature | Default | Enables | Extra dependencies |
|---|---|---|---|
usb | ✓ | transport::Usb, Brick::usb_first, Brick::usb_all, Brick::boot_sam_ba | nusb |
tracing | ✓ | Telegram logging at TRACE level | tracing |
bluetooth | transport::Bluetooth, Brick::bluetooth (Linux only) | bluer, tokio; system: D-Bus, pkg-config | |
serial | transport::Serial, Brick::serial | serialport, 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 byte | Meaning |
|---|---|
0x00 | Direct command, reply requested |
0x01 | System command, reply requested |
0x80 | Direct command, no reply |
0x81 | System command, no reply |
0x02 | Reply |
- All multi-byte values are little-endian.
- A telegram is at most 64 bytes.
- Status
0x00means 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
| Link | Framing |
|---|---|
| USB | None: one bulk transfer per telegram (endpoint 0x01 out, 0x82 in) |
| Bluetooth / serial | Each 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
| Field | Values |
|---|---|
| Port | 0–2 (A–C); 0xFF = all (only SetOutputState) |
| Power | -100…100 (signed byte) |
| Mode bits | 0x01 motor on, 0x02 brake, 0x04 regulated |
| Regulation | 0x00 idle, 0x01 speed, 0x02 sync |
| Turn ratio | -100…100 |
| Run state | 0x00 idle, 0x10 ramp up, 0x20 running, 0x40 ramp down |
| Tacho limit | u32 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
| Code | Status | Meaning |
|---|---|---|
0x20 | PendingCommunication | Transaction still in progress (I²C); retry |
0x40 | MailboxEmpty | The mailbox has no message |
0xBD | RequestFailed | Request failed, e.g. the file was not found |
0xBE | UnknownOpcode | Unknown command opcode |
0xBF | InsanePacket | Malformed telegram |
0xC0 | OutOfRange | Data contains out-of-range values |
0xDD | BusError | Communication bus error (no I²C answer) |
0xDE | CommBufferFull | No free memory in the communication buffer |
0xDF | InvalidChannel | Channel/connection not valid |
0xE0 | ChannelBusy | Channel/connection not configured or busy |
0xEC | NoActiveProgram | No program is running |
0xED | IllegalSize | Illegal size |
0xEE | IllegalMailbox | Illegal mailbox number |
0xEF | InvalidField | Invalid field of a structure |
0xF0 | BadInputOutput | Bad input or output port |
0xFB | InsufficientMemory | Not enough memory |
0xFF | BadArguments | Bad arguments |
System commands
| Code | Status | Meaning |
|---|---|---|
0x81 | NoMoreHandles | No more handles |
0x82 | NoSpace | No space |
0x83 | NoMoreFiles | No more files (end of a search) |
0x84 | EndOfFileExpected | End of file expected |
0x85 | EndOfFile | End of file |
0x86 | NotALinearFile | Not a linear file |
0x87 | FileNotFound | File not found |
0x88 | HandleAlreadyClosed | Handle already closed |
0x89 | NoLinearSpace | No contiguous space for a linear file |
0x8A | Undefined | Undefined error |
0x8B | FileBusy | File is busy |
0x8C | NoWriteBuffers | No write buffers |
0x8D | AppendNotPossible | Append not possible |
0x8E | FileFull | File is full |
0x8F | FileExists | File exists |
0x90 | ModuleNotFound | Module not found |
0x91 | OutOfBoundary | Out of boundary |
0x92 | IllegalFileName | Illegal file name |
0x93 | IllegalHandle | Illegal 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
SensorType | Code | Sensor | High-level type |
|---|---|---|---|
NoSensor | 0x00 | Nothing | — |
Switch | 0x01 | Touch sensor (NXT or RCX) | Touch |
Temperature | 0x02 | RCX temperature | RawSensor |
Reflection | 0x03 | RCX light | RawSensor |
Angle | 0x04 | RCX rotation | RawSensor |
LightActive | 0x05 | NXT light, floodlight on | Light<_, _, Reflected> |
LightInactive | 0x06 | NXT light, floodlight off | Light<_, _, Ambient> |
SoundDb | 0x07 | NXT sound, dB | Sound<_, _, Db> |
SoundDba | 0x08 | NXT sound, dBA | Sound<_, _, Dba> |
Custom | 0x09 | Custom analog | RawSensor |
LowSpeed | 0x0A | I²C, no 9 V | I2cBus, I2cDevice |
LowSpeed9V | 0x0B | I²C with 9 V supply | Ultrasonic, I2cBus |
HighSpeed | 0x0C | RS-485 (port 4) | — |
ColorFull | 0x0D | NXT 2.0 colour, detection | Color<_, _, Detect> |
ColorRed | 0x0E | Colour sensor, red lamp | Color<_, _, Lamp<Red>> |
ColorGreen | 0x0F | Colour sensor, green lamp | Color<_, _, Lamp<Green>> |
ColorBlue | 0x10 | Colour sensor, blue lamp | Color<_, _, Lamp<Blue>> |
ColorNone | 0x11 | Colour sensor, lamp off | Color<_, _, Ambient> |
Types 0x0C and up need NXT 2.0 firmware (1.28 or newer).
Modes
SensorMode | Code | scaled value |
|---|---|---|
Raw | 0x00 | Raw A/D value, 0–1023 |
Boolean | 0x20 | 0 or 1 |
TransitionCount | 0x40 | Number of 0↔1 transitions |
PeriodCount | 0x60 | Number of full 0→1→0 periods |
PercentFullScale | 0x80 | 0–100 |
Celsius | 0xA0 | Tenths of a degree Celsius (RCX temperature) |
Fahrenheit | 0xC0 | Tenths of a degree Fahrenheit |
AngleSteps | 0xE0 | RCX 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:
| Value | ColorName |
|---|---|
| 1 | Black |
| 2 | Blue |
| 3 | Green |
| 4 | Yellow |
| 5 | Red |
| 6 | White |
Ultrasonic sensor registers
The LEGO ultrasonic sensor is an I²C device at address 0x02:
| Register | Content |
|---|---|
0x00 | Version (8 bytes, e.g. V1.0) |
0x08 | Product ID (LEGO) |
0x10 | Sensor type (Sonar) |
0x41 | Command: 0 off, 1 single shot, 2 continuous, 3 event capture, 4 warm reset |
0x42–0x49 | Measurements 0–7 in cm (255 = no echo) |
Limits
| What | Limit | Where it matters |
|---|---|---|
| Telegram | 64 bytes | Everything |
| File name | 15 + . + 3 ASCII characters | FileName |
| Brick name | 15 ASCII characters | BrickName |
| Message | 58 bytes | Message, messages().send |
| Mailboxes | 10 inboxes, 10 response outboxes | Mailbox, RemoteMailbox |
| File read chunk | 58 bytes per request | system().read, MAX_READ |
| File write chunk | 61 bytes per request | system().write, MAX_WRITE |
| I/O-map read chunk | 55 bytes per request | MAX_IO_MAP_READ |
| I/O-map write chunk | 54 bytes per request | MAX_IO_MAP_WRITE |
| Poll chunk | 59 bytes | MAX_POLL |
| I²C transfer | 16 bytes each way | LsBytes |
| Motor power, turn ratio | −100 … 100 | Power, TurnRatio |
| Tone | 200 … 14 000 Hz, ≤ 65.535 s | Hz, Millis16 |
| Screen | 100 × 64 pixels, 800 bytes | display::Screen |
| Outputs / inputs | 3 / 4 | OutputPort, 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.
| Document | Used for |
|---|---|
| Bluetooth Developer Kit, Appendix 1: Communication protocol | System commands, system status codes, telegram format |
| Bluetooth Developer Kit, Appendix 2: Direct commands | Direct commands, direct status codes, sensor types and modes, output modes |
| Bluetooth Developer Kit, Appendix 3: ARM7 Bluetooth interface | Bluetooth framing |
| Hardware Developer Kit, ultrasonic sensor I²C appendix | Ultrasonic 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
nxtcrate 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_textclears the LCD and prints text: 8 lines of 16 characters in a built-in 5×7 pixel font.Brick::show_imageclears the LCD and shows an image.Brick::set_screenwrites a wholeScreento the LCD, andBrick::clear_screenblanks it.Screencan now be edited:Screen::blank,Screen::from_text,set_pixel,clear,draw_textanddraw_image.- New
display::Imagetype, a black and white picture of any size, built from a PBM file (Image::from_pbm,P1andP4), 8-bit grayscale pixels (Image::from_luma, orImage::from_luma_ditheredfor photos) or a function (Image::from_fn). - New constants
CHAR_WIDTH,CHAR_HEIGHT,TEXT_COLUMNSandTEXT_LINESdescribe the text grid.
- A
displayexample 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:
| Suite | What |
|---|---|
| doctests | Every example in the API docs and in this book runs against the simulator |
tests/protocol.rs | Byte-exact encoding and decoding of every command |
tests/proptest.rs | Property tests: decoders never panic, value ranges |
tests/high_level.rs | The high-level API against the simulator |
tests/compile_fail.rs | Things that must not compile (tests/ui/*.rs) |
tests/tiers.rs | The high-level API uses only the public low-level API |
tests/hardware.rs | Real-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.