Add USB CDC diagnostics, verified against real hardware
Presents a USB CDC serial port alongside the RS485 protocol, so the node can be verified with nothing but a USB cable — no SWD probe and no RS485 adapter. Motivated by having neither available: probe-rs needs SWD and defmt logs travel over RTT, so with only the USB bootloader attached there was no way to observe the sensor at all. Two tasks, both at the lowest priority so they can never delay a bus reply: - usb_irq (binds USBCTRL_IRQ) services the stack and drains the RX buffer, which has to happen even though the port is output-only. - usb_report emits one diagnostic line per second. The report deliberately exposes raw internal state rather than the protocol's flag encoding: "is the sensor working and what does it read" is a different question from what a bus master asks. Writes discard whatever does not fit rather than blocking, so an unattended port cannot stall the firmware. The USB bus allocator lives in an #[init(local = ...)] resource, which supplies the 'static the device and class borrow from without a static mut. Verified on hardware. The SHT31 reports serial 0x2d5ac752, read from its factory serial register and CRC-8 validated, with readings of 27.3 C and 43.5 %RH varying by a few LSB between samples — the noise floor of a live 16-bit conversion rather than a stuck cache. Costs ~17 KiB of flash and ~1.3 KiB of RAM, taking the image to ~34.5 KiB of 2 MiB and ~2.5 KiB of 256 KiB. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Generated
+22
-1
@@ -278,6 +278,12 @@ dependencies = [
|
||||
"nb 1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embedded-io"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
|
||||
|
||||
[[package]]
|
||||
name = "embedded-io"
|
||||
version = "0.7.1"
|
||||
@@ -816,7 +822,7 @@ dependencies = [
|
||||
"embedded-hal 1.0.0",
|
||||
"embedded-hal-async",
|
||||
"embedded-hal-nb",
|
||||
"embedded-io",
|
||||
"embedded-io 0.7.1",
|
||||
"frunk",
|
||||
"fugit",
|
||||
"itertools 0.10.5",
|
||||
@@ -1116,6 +1122,18 @@ dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "usbd-serial"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "065e4eaf93db81d5adac82d9cef8f8da314cb640fa7f89534b972383f1cf80fc"
|
||||
dependencies = [
|
||||
"embedded-hal 0.2.7",
|
||||
"embedded-io 0.6.1",
|
||||
"nb 1.1.0",
|
||||
"usb-device",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vcell"
|
||||
version = "0.1.3"
|
||||
@@ -1194,11 +1212,14 @@ dependencies = [
|
||||
"defmt-rtt",
|
||||
"embedded-hal 1.0.0",
|
||||
"fugit",
|
||||
"heapless",
|
||||
"panic-probe",
|
||||
"portable-atomic",
|
||||
"rp2040-boot2",
|
||||
"rp2040-hal",
|
||||
"rtic",
|
||||
"rtic-monotonics",
|
||||
"usb-device",
|
||||
"usbd-serial",
|
||||
"wiredsensor-core",
|
||||
]
|
||||
|
||||
@@ -235,7 +235,53 @@ Flash and watch `defmt` logs over SWD:
|
||||
cargo run --release -p wiredsensor-fw # runner is probe-rs
|
||||
```
|
||||
|
||||
Resource use is ~17 KiB of flash and ~1.2 KiB of RAM, of 2 MiB and 256 KiB.
|
||||
Readings are logged at `debug` level while `.cargo/config.toml` pins
|
||||
`DEFMT_LOG=info`, so use `DEFMT_LOG=debug cargo run …` to see them.
|
||||
|
||||
Resource use is ~34.5 KiB of flash and ~2.5 KiB of RAM, of 2 MiB and 256 KiB.
|
||||
Roughly half the flash is the USB stack.
|
||||
|
||||
## USB diagnostics
|
||||
|
||||
The node also presents a **USB CDC serial port**, so it can be verified with
|
||||
nothing but a USB cable — no SWD probe and no RS485 adapter. This is a diagnostic
|
||||
aid, not part of the protocol, and it runs at the lowest priority so it can never
|
||||
delay a bus reply.
|
||||
|
||||
With no debug probe available, flash over the bootloader instead: hold `BOOTSEL`
|
||||
while plugging in, then
|
||||
|
||||
```bash
|
||||
cp target/thumbv6m-none-eabi/release/wiredsensor-fw /tmp/fw.elf
|
||||
picotool load -x /tmp/fw.elf # picotool requires a known file extension
|
||||
```
|
||||
|
||||
The board appears as `16c0:27dd` "wiredsensor RS485 node". Read it with:
|
||||
|
||||
```bash
|
||||
stty -F /dev/ttyACM0 raw -echo && cat /dev/ttyACM0
|
||||
```
|
||||
|
||||
```
|
||||
=== wiredsensor v0.1.0 ===
|
||||
unit=0x01 baud=19200 gap=1822us
|
||||
SHT31 on I2C1 SDA=GP14 SCL=GP15 addr=0x44 100000Hz
|
||||
[ 82s] t=+27.349 C rh=+43.486 % age=985ms | OK fails=0
|
||||
sht_serial=0x2d5ac752 sht_status=0x0000 err[i2c=1 sht_crc=0 frame=0 bus_crc=0]
|
||||
```
|
||||
|
||||
A non-zero `sht_serial` is the useful signal: it is read from the SHT31's factory
|
||||
serial register and CRC-8 validated, so a missing or miswired sensor cannot fake
|
||||
it. Small jitter in the last digits of the readings is the genuine noise floor of
|
||||
a live 16-bit conversion — an identical repeated value would suggest a stuck
|
||||
cache instead.
|
||||
|
||||
The banner prints once at start-up. Output is dropped when no host is draining
|
||||
the port, rather than blocking the firmware, so hold the port open across a reset
|
||||
if you want to see it.
|
||||
|
||||
Note `16c0:27dd` is the pid.codes generic CDC-ACM pair, intended for development.
|
||||
Replace it before shipping.
|
||||
|
||||
## Design notes
|
||||
|
||||
@@ -264,6 +310,8 @@ drives it from registers thereafter.
|
||||
| 3 | `uart0_irq` | hardware | Empty the 32-byte RX FIFO, timestamp bytes |
|
||||
| 2 | `frame_gap` | async | Detect the frame gap, answer the request |
|
||||
| 1 | `sensor_task` | async | Poll the SHT31 once a second |
|
||||
| 1 | `usb_irq` | hardware | Service the USB CDC diagnostic port |
|
||||
| 1 | `usb_report` | async | Emit a diagnostic line once a second |
|
||||
| 1 | `heartbeat` | async | Blink the status LED |
|
||||
|
||||
The central decision is that **requests are answered entirely from a cached
|
||||
|
||||
@@ -35,3 +35,10 @@ fugit = "0.3"
|
||||
defmt = { workspace = true }
|
||||
defmt-rtt = "1.0"
|
||||
panic-probe = { version = "1.0", features = ["print-defmt"] }
|
||||
|
||||
# USB CDC diagnostics. Lets the node be verified over a plain USB cable with no
|
||||
# SWD probe attached. The usb-device version must track rp2040-hal's own so the
|
||||
# UsbBus trait implementations line up.
|
||||
usb-device = "0.3"
|
||||
usbd-serial = "0.2"
|
||||
heapless = "0.8"
|
||||
|
||||
@@ -119,6 +119,20 @@ pub const SENSOR_FAULT_THRESHOLD: u32 = 5;
|
||||
/// Status LED blink half-period.
|
||||
pub const HEARTBEAT_MS: u32 = 500;
|
||||
|
||||
// ------------------------------------------------------- USB diagnostics ---
|
||||
|
||||
/// How often to emit a diagnostic line on the USB serial port.
|
||||
pub const USB_REPORT_MS: u32 = 1_000;
|
||||
|
||||
/// USB vendor and product ID.
|
||||
///
|
||||
/// `16c0:27dd` is the pid.codes generic CDC-ACM pair, intended for development
|
||||
/// and in-house tooling. Replace it with your own allocation before shipping
|
||||
/// anything that leaves the building.
|
||||
pub const USB_VID: u16 = 0x16c0;
|
||||
/// See [`USB_VID`].
|
||||
pub const USB_PID: u16 = 0x27dd;
|
||||
|
||||
// -------------------------------------------------------------------- types ---
|
||||
|
||||
/// Instants from the 1 MHz timer backing the RTIC monotonic.
|
||||
|
||||
+188
-1
@@ -9,8 +9,14 @@
|
||||
//! | 3 | `uart0_irq` | hardware | Empty the 32-byte RX FIFO, timestamp bytes |
|
||||
//! | 2 | `frame_gap` | async | Detect the frame gap, answer the request |
|
||||
//! | 1 | `sensor_task` | async | Poll the SHT31 once a second |
|
||||
//! | 1 | `usb_irq` | hardware | Service the USB CDC diagnostic port |
|
||||
//! | 1 | `usb_report` | async | Emit a diagnostic line once a second |
|
||||
//! | 1 | `heartbeat` | async | Blink the status LED |
|
||||
//!
|
||||
//! The USB port is a diagnostic aid, not part of the protocol: it lets the node
|
||||
//! be verified over a plain USB cable with no SWD probe and no RS485 adapter. It
|
||||
//! sits at the lowest priority so it can never delay a bus reply.
|
||||
//!
|
||||
//! The key design decision is that **a request is answered entirely from a
|
||||
//! cached reading**. An SHT31 high-repeatability conversion takes up to 15 ms,
|
||||
//! which is far longer than the turnaround a master expects, so the sensor is
|
||||
@@ -50,11 +56,20 @@ pub static BOOT2_FIRMWARE: [u8; 256] = rp2040_boot2::BOOT_LOADER_W25Q080;
|
||||
mod app {
|
||||
use super::*;
|
||||
|
||||
use core::fmt::Write as _;
|
||||
|
||||
use embedded_hal::digital::StatefulOutputPin;
|
||||
use fugit::RateExtU32;
|
||||
use heapless::String;
|
||||
use rp2040_hal as hal;
|
||||
use rp2040_hal::usb::UsbBus;
|
||||
use rp2040_hal::Clock;
|
||||
use rtic::Mutex;
|
||||
use usb_device::{
|
||||
bus::UsbBusAllocator,
|
||||
device::{StringDescriptors, UsbDevice, UsbDeviceBuilder, UsbVidPid},
|
||||
};
|
||||
use usbd_serial::SerialPort;
|
||||
use wiredsensor_core::{
|
||||
frame::{ParseError, MAX_FRAME},
|
||||
proto::{self, flag, Info, Measurement, Outcome, Snapshot, Status},
|
||||
@@ -104,6 +119,10 @@ mod app {
|
||||
reading: Option<Reading>,
|
||||
/// Counters and health bits.
|
||||
state: DeviceState,
|
||||
/// USB CDC diagnostics. Independent of the RS485 protocol — these exist
|
||||
/// so the node can be verified over a plain USB cable with no SWD probe.
|
||||
usb_dev: UsbDevice<'static, UsbBus>,
|
||||
serial: SerialPort<'static, UsbBus>,
|
||||
}
|
||||
|
||||
#[local]
|
||||
@@ -112,7 +131,9 @@ mod app {
|
||||
led: board::StatusLedPin,
|
||||
}
|
||||
|
||||
#[init]
|
||||
// The USB bus allocator must outlive the device and class that borrow from
|
||||
// it. An init-local resource gives us that `'static` without a `static mut`.
|
||||
#[init(local = [usb_bus: Option<UsbBusAllocator<UsbBus>> = None])]
|
||||
fn init(mut cx: init::Context) -> (Shared, Local) {
|
||||
let mut watchdog = hal::watchdog::Watchdog::new(cx.device.WATCHDOG);
|
||||
let clocks = hal::clocks::init_clocks_and_plls(
|
||||
@@ -169,6 +190,28 @@ mod app {
|
||||
|
||||
let led = pins.gpio25.reconfigure();
|
||||
|
||||
// ---- USB CDC diagnostics --------------------------------------------
|
||||
// `force_vbus_detect_bit` is true because the Pico ties VBUS to the chip;
|
||||
// on a board with no VBUS sense this makes the block enumerate whenever
|
||||
// it is powered, which is harmless but not free.
|
||||
let usb_bus: &'static UsbBusAllocator<UsbBus> =
|
||||
cx.local.usb_bus.insert(UsbBusAllocator::new(UsbBus::new(
|
||||
cx.device.USBCTRL_REGS,
|
||||
cx.device.USBCTRL_DPRAM,
|
||||
clocks.usb_clock,
|
||||
true,
|
||||
&mut cx.device.RESETS,
|
||||
)));
|
||||
let serial = SerialPort::new(usb_bus);
|
||||
let usb_dev = UsbDeviceBuilder::new(usb_bus, UsbVidPid(board::USB_VID, board::USB_PID))
|
||||
.strings(&[StringDescriptors::default()
|
||||
.manufacturer("wiredsensor")
|
||||
.product("wiredsensor RS485 node")
|
||||
.serial_number("0001")])
|
||||
.expect("USB string descriptors")
|
||||
.device_class(usbd_serial::USB_CLASS_CDC)
|
||||
.build();
|
||||
|
||||
defmt::info!(
|
||||
"wiredsensor v{}.{}.{} up: unit {:#04x}, {} baud, gap {} us",
|
||||
board::FW_MAJOR,
|
||||
@@ -181,6 +224,7 @@ mod app {
|
||||
|
||||
sensor_task::spawn().ok();
|
||||
heartbeat::spawn().ok();
|
||||
usb_report::spawn().ok();
|
||||
|
||||
(
|
||||
Shared {
|
||||
@@ -188,6 +232,8 @@ mod app {
|
||||
rx: RxBuffer::new(),
|
||||
reading: None,
|
||||
state: DeviceState::default(),
|
||||
usb_dev,
|
||||
serial,
|
||||
},
|
||||
Local { sensor, led },
|
||||
)
|
||||
@@ -534,4 +580,145 @@ mod app {
|
||||
Mono::delay(board::Duration::millis(u64::from(board::HEARTBEAT_MS))).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Service the USB stack.
|
||||
///
|
||||
/// Lowest priority: the RS485 bus must always win, and the host tolerates the
|
||||
/// resulting jitter. This is safe even during a long reply because `transmit`
|
||||
/// yields between FIFO refills rather than spinning.
|
||||
#[task(binds = USBCTRL_IRQ, priority = 1, shared = [usb_dev, serial])]
|
||||
fn usb_irq(mut cx: usb_irq::Context) {
|
||||
cx.shared.usb_dev.lock(|dev| {
|
||||
cx.shared.serial.lock(|serial| {
|
||||
if dev.poll(&mut [serial]) {
|
||||
// Output-only diagnostic, but the RX buffer still has to be
|
||||
// drained or the host's writes would eventually stall it.
|
||||
let mut discard = [0u8; 64];
|
||||
let _ = serial.read(&mut discard);
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/// Emit a diagnostic line on the USB serial port once a second.
|
||||
///
|
||||
/// Deliberately reports raw internal state rather than the protocol's flag
|
||||
/// encoding: this exists to answer "is the sensor working and what does it
|
||||
/// read", which is a different question from what a bus master asks.
|
||||
#[task(priority = 1, shared = [reading, state, serial])]
|
||||
async fn usb_report(mut cx: usb_report::Context) {
|
||||
let mut line: String<256> = String::new();
|
||||
|
||||
line.clear();
|
||||
let _ = write!(
|
||||
line,
|
||||
"\r\n=== wiredsensor v{}.{}.{} ===\r\n\
|
||||
unit={:#04x} baud={} gap={}us\r\n\
|
||||
SHT31 on I2C1 SDA=GP14 SCL=GP15 addr={:#04x} {}Hz\r\n",
|
||||
board::FW_MAJOR,
|
||||
board::FW_MINOR,
|
||||
board::FW_PATCH,
|
||||
board::UNIT_ADDRESS,
|
||||
board::BAUD_RATE,
|
||||
board::INTER_FRAME_GAP_US,
|
||||
board::SENSOR_I2C_ADDR,
|
||||
board::I2C_FREQ_HZ,
|
||||
);
|
||||
cx.shared
|
||||
.serial
|
||||
.lock(|serial| usb_write(serial, line.as_bytes()));
|
||||
|
||||
loop {
|
||||
Mono::delay(board::Duration::millis(u64::from(board::USB_REPORT_MS))).await;
|
||||
|
||||
let now = Mono::now();
|
||||
let reading = cx.shared.reading.lock(|slot| {
|
||||
slot.as_ref().map(|r| {
|
||||
let age = now
|
||||
.checked_duration_since(r.taken_at)
|
||||
.map(|d| d.to_millis())
|
||||
.unwrap_or(0);
|
||||
(r.temp_milli_c, r.rh_milli_pct, age)
|
||||
})
|
||||
});
|
||||
|
||||
let s = cx.shared.state.lock(|s| {
|
||||
(
|
||||
s.sensor_ok,
|
||||
s.consecutive_failures,
|
||||
s.sensor_status,
|
||||
s.sensor_serial,
|
||||
s.i2c_errors,
|
||||
s.sensor_crc_errors,
|
||||
s.frame_errors,
|
||||
s.crc_errors,
|
||||
)
|
||||
});
|
||||
let (ok, fails, sht_status, sht_serial, i2c_err, sht_crc_err, frame_err, bus_crc_err) =
|
||||
s;
|
||||
|
||||
line.clear();
|
||||
let _ = write!(line, "[{:>6}s] ", now.ticks() / 1_000_000);
|
||||
|
||||
match reading {
|
||||
Some((temp, rh, age)) => {
|
||||
let _ = write!(line, "t=");
|
||||
write_milli(&mut line, temp);
|
||||
let _ = write!(line, " C rh=");
|
||||
write_milli(&mut line, rh);
|
||||
let _ = write!(line, " % age={}ms", age);
|
||||
}
|
||||
None if fails > 0 => {
|
||||
let _ = write!(line, "NO READING - sensor not responding");
|
||||
}
|
||||
None => {
|
||||
let _ = write!(line, "waiting for first measurement");
|
||||
}
|
||||
}
|
||||
|
||||
let _ = write!(
|
||||
line,
|
||||
" | {} fails={} sht_serial={:#010x} sht_status={:#06x} \
|
||||
err[i2c={} sht_crc={} frame={} bus_crc={}]\r\n",
|
||||
if ok { "OK " } else { "ERR" },
|
||||
fails,
|
||||
sht_serial,
|
||||
sht_status,
|
||||
i2c_err,
|
||||
sht_crc_err,
|
||||
frame_err,
|
||||
bus_crc_err,
|
||||
);
|
||||
|
||||
cx.shared
|
||||
.serial
|
||||
.lock(|serial| usb_write(serial, line.as_bytes()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `bytes` to the host, discarding whatever does not fit.
|
||||
///
|
||||
/// A diagnostic that stalls the firmware when nobody is draining the port
|
||||
/// would be worse than no diagnostic at all, so a full buffer just drops the
|
||||
/// rest of the line.
|
||||
fn usb_write(serial: &mut SerialPort<'static, UsbBus>, mut bytes: &[u8]) {
|
||||
while !bytes.is_empty() {
|
||||
match serial.write(bytes) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => bytes = &bytes[n..],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render milli-units as a signed fixed-point decimal: `-1234` → `-1.234`.
|
||||
fn write_milli<const N: usize>(out: &mut String<N>, milli: i32) {
|
||||
let magnitude = milli.unsigned_abs();
|
||||
let _ = write!(
|
||||
out,
|
||||
"{}{}.{:03}",
|
||||
if milli < 0 { '-' } else { '+' },
|
||||
magnitude / 1000,
|
||||
magnitude % 1000
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user