diff --git a/Cargo.lock b/Cargo.lock index 2c9f1d4..6b9c187 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", ] diff --git a/README.md b/README.md index 811f055..e6a1a8f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/firmware/Cargo.toml b/firmware/Cargo.toml index 3d29dd0..d070279 100644 --- a/firmware/Cargo.toml +++ b/firmware/Cargo.toml @@ -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" diff --git a/firmware/src/board.rs b/firmware/src/board.rs index e1c8362..47f9207 100644 --- a/firmware/src/board.rs +++ b/firmware/src/board.rs @@ -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. diff --git a/firmware/src/main.rs b/firmware/src/main.rs index 40294e6..0ce6f9b 100644 --- a/firmware/src/main.rs +++ b/firmware/src/main.rs @@ -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, /// 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> = 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 = + 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(out: &mut String, milli: i32) { + let magnitude = milli.unsigned_abs(); + let _ = write!( + out, + "{}{}.{:03}", + if milli < 0 { '-' } else { '+' }, + magnitude / 1000, + magnitude % 1000 + ); + } }