Add USB-RS485 bridge and PC-side test suite

Makes end-to-end verification of the bus possible with a spare RP2040 and
no dedicated RS485 dongle.

bridge/ turns a second Pico into a transparent USB-to-RS485 bridge. It is
deliberately protocol-agnostic: bytes from USB go out on the pair, bytes
from the pair go back up USB, and it knows nothing about frames,
addresses or CRCs. A protocol-aware bridge could have a bug that happens
to agree with the node's own, which would defeat the point of using it as
a test instrument. It also needs no register-level code — it never has to
delimit a frame, and the one thing it does need, knowing that the final
stop bit has cleared the shifter before releasing DE, rp2040-hal exposes
as uart_is_busy().

tools/wiredsensor.py is an independent implementation of the wire format,
written from the specification in README.md rather than sharing code with
wiredsensor-core. Shared code would let a framing or CRC bug cancel out
and every test pass regardless. Cross-checked: it reproduces the
CRC-16/MODBUS catalogue vector and builds byte-identical frames to the
Rust side for all five documented examples.

Its `test` subcommand covers what host tests structurally cannot, all of
it timing-dependent: that malformed and mis-addressed frames leave the
node silent rather than colliding with whoever was actually addressed,
that the driver-enable turnaround leaves replies intact, and that no
state leaks between back-to-back frames.

Uses one wiring diagram for both boards, since the bridge mirrors the
node's GPIO0/1/2 assignment. Frames must fit one 64-byte USB packet, as
each host write becomes exactly one DE-bracketed transmission; every real
command is at most 21 bytes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Walter
2026-07-28 20:31:30 +02:00
parent bcc6869965
commit ddfda14085
8 changed files with 796 additions and 1 deletions
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "wiredsensor-bridge"
description = "Turns a spare RP2040 into a USB-to-RS485 bridge for testing the bus"
version.workspace = true
edition.workspace = true
license.workspace = true
[[bin]]
name = "wiredsensor-bridge"
path = "src/main.rs"
test = false
bench = false
[dependencies]
rp2040-hal = { version = "0.12", features = ["rt", "critical-section-impl"] }
rp2040-boot2 = "0.3"
cortex-m = "0.7"
cortex-m-rt = "0.7"
embedded-hal = "1.0"
fugit = "0.3"
usb-device = "0.3"
usbd-serial = "0.2"
panic-halt = "1.0"
+18
View File
@@ -0,0 +1,18 @@
use std::{env, fs, path::PathBuf};
fn main() {
// Copy memory.x somewhere the linker will find it. Relying on the linker's
// working directory does not work in a workspace, where cargo runs rustc
// from the workspace root rather than this package's directory.
let out = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR"));
fs::copy("memory.x", out.join("memory.x")).expect("copy memory.x into OUT_DIR");
println!("cargo:rustc-link-search={}", out.display());
// --nmagic disables page alignment of sections; without it the vector table
// is pushed away from the start of flash and the boot ROM cannot find it.
println!("cargo:rustc-link-arg-bins=--nmagic");
println!("cargo:rustc-link-arg-bins=-Tlink.x");
println!("cargo:rerun-if-changed=memory.x");
println!("cargo:rerun-if-changed=build.rs");
}
+21
View File
@@ -0,0 +1,21 @@
/* RP2040 with 2 MiB of QSPI flash.
*
* The boot ROM reads the first 256 bytes of flash into RAM and executes them to
* bring up XIP, so .boot2 must sit at the very start and the vector table must
* follow immediately after it. */
MEMORY {
BOOT2 : ORIGIN = 0x10000000, LENGTH = 0x100
FLASH : ORIGIN = 0x10000100, LENGTH = 2048K - 0x100
/* The four 64 KiB SRAM banks are striped, so treat them as one region. */
RAM : ORIGIN = 0x20000000, LENGTH = 256K
}
EXTERN(BOOT2_FIRMWARE)
SECTIONS {
.boot2 ORIGIN(BOOT2) :
{
KEEP(*(.boot2));
} > BOOT2
} INSERT BEFORE .text;
+177
View File
@@ -0,0 +1,177 @@
//! USB-to-RS485 bridge: turns a spare RP2040 into a PC interface for the bus.
//!
//! Deliberately **protocol-agnostic**. Bytes arriving from USB go out on the
//! differential pair; bytes arriving from the pair go back up USB. It knows
//! nothing about frames, addresses or CRCs, which is what makes it usable as a
//! test instrument: any bug it might have cannot be a bug that happens to agree
//! with the node's own protocol implementation.
//!
//! Unlike the sensor node this needs no register-level access. It never has to
//! delimit a frame, so the receive-timeout interrupt is irrelevant, and the one
//! thing it does need — knowing when the last stop bit has left the shifter, so
//! the driver can be released — `rp2040-hal` exposes directly as
//! `uart_is_busy()`.
//!
//! # Limitation worth knowing
//!
//! Each USB write from the PC becomes exactly one transmission, bracketed by DE.
//! A frame split across two USB packets would therefore be sent as two bursts
//! with an inter-frame gap between them, and the node would see two malformed
//! frames rather than one good one. USB full-speed bulk packets are 64 bytes, so
//! keep frames at or under 64 bytes — every real command in this protocol is at
//! most 21, and only an oversized `PING` payload could reach the limit.
#![no_std]
#![no_main]
use cortex_m_rt::entry;
use embedded_hal::digital::OutputPin;
use fugit::RateExtU32;
use panic_halt as _;
use rp2040_hal::{
clocks::init_clocks_and_plls,
gpio::{FunctionUart, Pins, PullNone, PullUp},
pac,
uart::{DataBits, StopBits, UartConfig, UartPeripheral},
usb::UsbBus,
watchdog::Watchdog,
Clock, Sio,
};
use usb_device::{
bus::UsbBusAllocator,
device::{StringDescriptors, UsbDeviceBuilder, UsbVidPid},
};
use usbd_serial::SerialPort;
/// Line rate. Must match the sensor node's `board::BAUD_RATE`.
const BAUD_RATE: u32 = 19_200;
/// Crystal fitted to the board.
const XTAL_FREQ_HZ: u32 = 12_000_000;
/// Product ID differs from the node's `0x27dd` so the two boards are tellable
/// apart when both are plugged into the same host.
const USB_VID: u16 = 0x16c0;
/// See [`USB_VID`].
const USB_PID: u16 = 0x27de;
/// Second-stage bootloader; see the node's firmware for the details.
#[link_section = ".boot2"]
#[no_mangle]
#[used]
pub static BOOT2_FIRMWARE: [u8; 256] = rp2040_boot2::BOOT_LOADER_W25Q080;
#[entry]
fn main() -> ! {
let mut pac = pac::Peripherals::take().unwrap();
let mut watchdog = Watchdog::new(pac.WATCHDOG);
let clocks = init_clocks_and_plls(
XTAL_FREQ_HZ,
pac.XOSC,
pac.CLOCKS,
pac.PLL_SYS,
pac.PLL_USB,
&mut pac.RESETS,
&mut watchdog,
)
.expect("clock init failed — check XTAL_FREQ_HZ against the fitted crystal");
let sio = Sio::new(pac.SIO);
let pins = Pins::new(
pac.IO_BANK0,
pac.PADS_BANK0,
sio.gpio_bank0,
&mut pac.RESETS,
);
// Same pin assignment as the sensor node, so one wiring diagram covers both
// boards: GPIO0 -> DI, GPIO1 <- RO, GPIO2 -> DE and /RE.
let uart_pins = (
pins.gpio0.reconfigure::<FunctionUart, PullNone>(),
pins.gpio1.reconfigure::<FunctionUart, PullUp>(),
);
let uart = UartPeripheral::new(pac.UART0, uart_pins, &mut pac.RESETS)
.enable(
UartConfig::new(BAUD_RATE.Hz(), DataBits::Eight, None, StopBits::One),
clocks.peripheral_clock.freq(),
)
.expect("uart baud rate not achievable from the peripheral clock");
let mut de = pins.gpio2.into_push_pull_output();
de.set_low().ok();
let mut led = pins.gpio25.into_push_pull_output();
led.set_low().ok();
let usb_bus = UsbBusAllocator::new(UsbBus::new(
pac.USBCTRL_REGS,
pac.USBCTRL_DPRAM,
clocks.usb_clock,
true,
&mut pac.RESETS,
));
let mut serial = SerialPort::new(&usb_bus);
let mut usb_dev = UsbDeviceBuilder::new(&usb_bus, UsbVidPid(USB_VID, USB_PID))
.strings(&[StringDescriptors::default()
.manufacturer("wiredsensor")
.product("wiredsensor RS485 bridge")
.serial_number("bridge")])
.expect("USB string descriptors")
.device_class(usbd_serial::USB_CLASS_CDC)
.build();
let mut from_pc = [0u8; 128];
let mut from_bus = [0u8; 64];
loop {
usb_dev.poll(&mut [&mut serial]);
// ---- PC -> RS485 ----------------------------------------------------
if let Ok(n) = serial.read(&mut from_pc) {
if n > 0 {
led.set_high().ok();
de.set_high().ok();
let mut pending = &from_pc[..n];
while !pending.is_empty() {
if let Ok(rest) = uart.write_raw(pending) {
pending = rest;
}
// Keep the host serviced while the line drains; a 64-byte
// burst takes 33 ms at 19200 baud, which is long enough that
// ignoring USB through it would risk the host giving up.
usb_dev.poll(&mut [&mut serial]);
}
// Hold the driver until the final stop bit is genuinely gone.
// Releasing at FIFO-empty would truncate the last character.
while uart.uart_is_busy() {}
de.set_low().ok();
// Discard the turnaround glitch. While DE was asserted the
// transceiver's /RE was disabled and RO undriven, so the edge as
// it re-enables can clock in a spurious character. The node waits
// out a full inter-frame gap before replying, so this cannot eat
// any of the real response.
while uart.read_raw(&mut from_bus).is_ok() {}
led.set_low().ok();
}
}
// ---- RS485 -> PC ----------------------------------------------------
// Line errors are dropped along with their bytes: a test instrument that
// silently repaired corruption would hide exactly the faults we want to
// catch, and the node counts its own receive errors anyway.
if let Ok(n) = uart.read_raw(&mut from_bus) {
let mut pending = &from_bus[..n];
while !pending.is_empty() {
match serial.write(pending) {
Ok(0) | Err(_) => break,
Ok(written) => pending = &pending[written..],
}
usb_dev.poll(&mut [&mut serial]);
}
}
}
}