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:
@@ -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"
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user