//! RS485 temperature and humidity node: RP2040 + SHT31. //! //! # Concurrency structure //! //! Three priority levels, chosen so the bus is never the thing that waits: //! //! | Prio | Task | Kind | Job | //! |------|---------------|----------|--------------------------------------------| //! | 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 | `status_led` | async | Repaint the WS2812B indicator | //! //! 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 //! polled on its own schedule at the lowest priority and `frame_gap` never //! touches I2C. RTIC's priority ceilings then guarantee that a conversion in //! progress cannot delay a reply, and the compiler checks the resulting resource //! sharing rather than us reasoning about it. #![no_std] #![no_main] mod board; mod rs485; mod sensor; mod status_led; use defmt_rtt as _; use panic_probe as _; use rtic_monotonics::rp2040::prelude::*; // A 1 MHz, 64-bit monotonic on the RP2040 TIMER peripheral. This claims // TIMER_IRQ_0, which is why the RTIC dispatchers below use the software-only // vectors instead. rp2040_timer_monotonic!(Mono); /// Second-stage bootloader. /// /// The boot ROM copies these 256 bytes into RAM and runs them to configure XIP /// for the attached QSPI flash before any of our code executes. `W25Q080` covers /// the parts fitted to the Pico and most RP2040 modules; change it if the board /// carries something else. #[link_section = ".boot2"] #[no_mangle] #[used] pub static BOOT2_FIRMWARE: [u8; 256] = rp2040_boot2::BOOT_LOADER_W25Q080; #[rtic::app(device = rp2040_hal::pac, peripherals = true, dispatchers = [SW0_IRQ, SW1_IRQ])] mod app { use super::*; use core::fmt::Write as _; 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}, sht3x, }; use crate::board::{self, Instant}; use crate::rs485::{self, Rs485, RxBuffer, FIFO_DEPTH}; use crate::sensor::Sht31; use crate::status_led::{Rgb, StatusLed}; /// A cached sensor reading and when it was taken. /// /// `pub` because RTIC's generated resource proxies name it in their public /// signatures. pub struct Reading { temp_milli_c: i32, rh_milli_pct: i32, taken_at: Instant, } /// Everything behind the diagnostic and identity registers that is /// discovered at runtime rather than fixed at build time. #[derive(Default)] pub struct DeviceState { /// Factory serial read from the sensor at start-up, 0 if unavailable. sensor_serial: u32, /// Raw SHT31 status register from the most recent successful read. sensor_status: u16, i2c_errors: u16, sensor_crc_errors: u16, frame_errors: u16, crc_errors: u16, consecutive_failures: u32, sensor_ok: bool, ever_measured: bool, uart_error: bool, sensor_reset_seen: bool, } #[shared] struct Shared { /// The PL011 and the transceiver direction pin. bus: Rs485, /// Bytes received since the last inter-frame gap. rx: RxBuffer, /// Latest reading, `None` until the first successful poll. 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] struct Local { sensor: Sht31, led: StatusLed, } // 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( board::XTAL_FREQ_HZ, cx.device.XOSC, cx.device.CLOCKS, cx.device.PLL_SYS, cx.device.PLL_USB, &mut cx.device.RESETS, &mut watchdog, ) .expect("clock init failed — check XTAL_FREQ_HZ against the fitted crystal"); Mono::start(cx.device.TIMER, &cx.device.RESETS); let sio = hal::Sio::new(cx.device.SIO); let pins = hal::gpio::Pins::new( cx.device.IO_BANK0, cx.device.PADS_BANK0, sio.gpio_bank0, &mut cx.device.RESETS, ); // ---- RS485 on UART0 ------------------------------------------------ // Let the HAL do the baud-divisor and line-control setup, which is // fiddly and easy to get subtly wrong, then reclaim the raw peripheral: // the runtime path needs RTIM and BUSY, which the HAL does not expose. let uart_pins: board::Rs485Pins = (pins.gpio0.reconfigure(), pins.gpio1.reconfigure()); let uart = hal::uart::UartPeripheral::new(cx.device.UART0, uart_pins, &mut cx.device.RESETS) .enable( hal::uart::UartConfig::new( board::BAUD_RATE.Hz(), hal::uart::DataBits::Eight, None, hal::uart::StopBits::One, ), clocks.peripheral_clock.freq(), ) .expect("uart baud rate not achievable from the peripheral clock"); let (uart0, uart_pins) = uart.free(); let bus = Rs485::new(uart0, uart_pins, pins.gpio2.reconfigure()); // ---- SHT31 on I2C1 -------------------------------------------------- let i2c = hal::i2c::I2C::new_controller( cx.device.I2C1, pins.gpio14.reconfigure(), pins.gpio15.reconfigure(), board::I2C_FREQ_HZ.Hz(), &mut cx.device.RESETS, clocks.system_clock.freq(), ); let sensor = Sht31::new(i2c, board::SENSOR_I2C_ADDR); // ---- WS2812B status indicator on PIO0 -------------------------------- let led = StatusLed::new( cx.device.PIO0, pins.gpio16.reconfigure(), &mut cx.device.RESETS, clocks.system_clock.freq(), ); // ---- 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, board::FW_MINOR, board::FW_PATCH, board::UNIT_ADDRESS, board::BAUD_RATE, board::INTER_FRAME_GAP_US, ); sensor_task::spawn().ok(); status_led::spawn().ok(); usb_report::spawn().ok(); ( Shared { bus, rx: RxBuffer::new(), reading: None, state: DeviceState::default(), usb_dev, serial, }, Local { sensor, led }, ) } /// Highest-priority work in the system: get bytes out of the FIFO before it /// overruns and record when the last one arrived. /// /// Deliberately does no parsing. At 19200 baud a character arrives every /// ~520 µs — around 65,000 core cycles — so there is plenty of slack, but /// keeping the ISR to a FIFO drain is what bounds the jitter that everything /// else in the system sees. #[task(binds = UART0_IRQ, priority = 3, shared = [bus, rx])] fn uart0_irq(mut cx: uart0_irq::Context) { let now = Mono::now(); let start_gap_timer = cx.shared.bus.lock(|bus| { cx.shared.rx.lock(|rx| { bus.drain_into(rx, now); // One gap-detection task per frame. It re-reads `last_byte_at` // itself, so bytes arriving later need no further spawn. if rx.len > 0 && !rx.gap_pending { rx.gap_pending = true; true } else { false } }) }); if start_gap_timer { frame_gap::spawn().ok(); } } /// Wait out the inter-frame gap, then answer the frame. /// /// Runs above the sensor so a conversion in progress can never delay a reply, /// and below the UART ISR so incoming bytes are never dropped while we work. #[task(priority = 2, shared = [bus, rx, reading, state])] async fn frame_gap(mut cx: frame_gap::Context) { let gap = board::Duration::micros(board::INTER_FRAME_GAP_US as u64); // Bytes may still be arriving. Rather than cancelling and respawning a // timer on every byte, drain whatever has landed, re-read the last // arrival time, and sleep again. // // Draining here as well as in the ISR is load-bearing, not belt and // braces. The watermark interrupt fires mid-frame once 16 bytes are in // the FIFO; the rest of the frame then sits *below* the watermark and // raises no interrupt at all until the PL011's receive timeout // eventually fires. Measuring the gap from the ISR's mid-frame // timestamp would therefore declare the frame complete while its tail // was still arriving, truncating every frame longer than the watermark. // Draining before each decision means a byte that arrived can always // push the deadline back. // // The cost is that the gap is measured from when we observed the byte // rather than when it landed, so the reply is up to one gap period // later than strictly necessary. Correctness is worth 1.8 ms. loop { let last_byte_at = cx.shared.bus.lock(|bus| { cx.shared.rx.lock(|rx| { bus.drain_into(rx, Mono::now()); rx.last_byte_at }) }); let deadline = last_byte_at + gap; if Mono::now() >= deadline { break; } Mono::delay_until(deadline).await; } // Take the frame out of the shared buffer so the ISR can begin the next. let mut req = [0u8; MAX_FRAME]; let (len, overflow, line_error) = cx.shared.rx.lock(|rx| { let len = rx.len.min(MAX_FRAME); req[..len].copy_from_slice(&rx.buf[..len]); let taken = (len, rx.overflow, rx.line_error); rx.reset(); taken }); // A frame that outgrew the buffer or arrived with line errors is not // trustworthy. Count it and stay off the wire: answering a frame whose // address byte we cannot trust risks colliding with the node that was // actually addressed. if overflow || line_error { cx.shared.state.lock(|s| { s.uart_error |= line_error; s.frame_errors = s.frame_errors.saturating_add(1); }); defmt::warn!( "discarded frame: {} bytes, overflow={}, line_error={}", len, overflow, line_error ); cx.shared.rx.lock(|rx| rx.gap_pending = false); return; } let snapshot = snapshot(&mut cx); let mut out = [0u8; MAX_FRAME]; let reply_len = match proto::dispatch(board::UNIT_ADDRESS, &req[..len], &snapshot, &mut out) { Outcome::Reply(reply) => Some(reply.len()), Outcome::Silent => None, Outcome::Malformed(e) => { cx.shared.state.lock(|s| match e { ParseError::BadCrc => s.crc_errors = s.crc_errors.saturating_add(1), _ => s.frame_errors = s.frame_errors.saturating_add(1), }); defmt::debug!("rejected frame of {} bytes: {}", len, e); None } }; if let Some(n) = reply_len { transmit(&mut cx, &out[..n]).await; } cx.shared.rx.lock(|rx| rx.gap_pending = false); } /// Assemble the device state `dispatch` is allowed to see. /// /// Locks are taken one resource at a time: a reading and the counters need /// not be mutually consistent, and holding both at once would raise the /// ceiling for no benefit. fn snapshot(cx: &mut frame_gap::Context<'_>) -> Snapshot { let now = Mono::now(); let measurement = cx.shared.reading.lock(|slot| { slot.as_ref().map(|r| { let age_ms = now .checked_duration_since(r.taken_at) .map(|d| d.to_millis()) .unwrap_or(0); Measurement { temp_milli_c: r.temp_milli_c, rh_milli_pct: r.rh_milli_pct, // Saturating at ~65 s is fine: anything that old is already // far past the staleness threshold. age_ms: age_ms.min(u16::MAX as u64) as u16, } }) }); let stale = measurement .map(|m| u32::from(m.age_ms) >= board::STALE_AFTER_MS) .unwrap_or(true); let (info, status) = cx.shared.state.lock(|s| { let mut flags = 0u16; if s.sensor_ok { flags |= flag::SENSOR_OK; } if stale { flags |= flag::DATA_STALE; } if s.consecutive_failures >= board::SENSOR_FAULT_THRESHOLD { flags |= flag::SENSOR_FAULT; } if s.uart_error { flags |= flag::UART_ERROR; } if s.ever_measured { flags |= flag::EVER_MEASURED; } if s.sensor_reset_seen { flags |= flag::SENSOR_RESET_SEEN; } if s.sensor_status & sht3x::status::HEATER_ON != 0 { flags |= flag::HEATER_ON; } let info = Info { fw_major: board::FW_MAJOR, fw_minor: board::FW_MINOR, fw_patch: board::FW_PATCH, proto_version: proto::PROTOCOL_VERSION, device_serial: board::DEVICE_SERIAL, sensor_serial: s.sensor_serial, uptime_s: (now.ticks() / 1_000_000).min(u64::from(u32::MAX)) as u32, }; let status = Status { flags, sensor_status: s.sensor_status, i2c_errors: s.i2c_errors, sensor_crc_errors: s.sensor_crc_errors, frame_errors: s.frame_errors, crc_errors: s.crc_errors, }; (info, status) }); Snapshot { measurement, info, status, } } /// Put `reply` on the wire, holding DE for exactly as long as it takes. /// /// Between FIFO refills this yields rather than spinning, so the sensor task /// still gets to run. That is safe because DE stays asserted throughout: we /// own the bus for the whole call, and nothing else in the system is allowed /// to touch the transceiver. async fn transmit(cx: &mut frame_gap::Context<'_>, reply: &[u8]) { cx.shared.bus.lock(|bus| bus.tx_enable()); let mut remaining = reply; while !remaining.is_empty() { remaining = cx.shared.bus.lock(|bus| bus.push(remaining)); if !remaining.is_empty() { // FIFO full — wait for roughly half of it to drain before // topping up. Mono::delay(board::Duration::micros( rs485::tx_time_us(FIFO_DEPTH / 2) as u64 )) .await; } } // Everything is queued; at most a full FIFO is still to go. Sleep through // the bulk of it, then confirm with BUSY, which is the only signal that // the final stop bit has actually left the shift register. let queued = reply.len().min(FIFO_DEPTH); Mono::delay(board::Duration::micros(rs485::tx_time_us(queued) as u64)).await; while !cx.shared.bus.lock(|bus| bus.tx_complete()) { Mono::delay(board::Duration::micros(board::CHAR_TIME_US as u64)).await; } cx.shared.bus.lock(|bus| bus.tx_disable()); } /// Sensor bring-up followed by periodic polling. /// /// Lowest priority on purpose: nothing here is time-critical, and the bus /// must always be able to preempt it. #[task(priority = 1, shared = [reading, state], local = [sensor])] async fn sensor_task(mut cx: sensor_task::Context) { let sensor = cx.local.sensor; Mono::delay(board::Duration::millis(u64::from(sht3x::POWER_UP_MAX_MS))).await; // Bring-up. Every command is followed by quiet time, because the sensor // NACKs anything arriving while it is still busy with the previous one. // Without the settle after `clear_status` the first measurement below // failed on every boot, leaving a permanent i2c_errors=1 that a master // polling the error counters could not distinguish from a real fault. // // Failures here are logged but deliberately not counted: the counters // describe operational health, and an unreadable serial already shows up // as a zero in the sensor-serial registers. let settle = board::Duration::millis(u64::from(sht3x::COMMAND_SETTLE_MS)); // Clear whatever state a warm reset left behind, so the sensor's // configuration is known regardless of how we got here. let _ = sensor.soft_reset(); Mono::delay(board::Duration::millis(u64::from(sht3x::SOFT_RESET_MAX_MS))).await; match sensor.read_serial() { Ok(serial) => { defmt::info!("SHT31 serial {=u32:#010x}", serial); cx.shared.state.lock(|s| s.sensor_serial = serial); } Err(e) => defmt::warn!("SHT31 serial read failed: {}", e), } Mono::delay(settle).await; let _ = sensor.clear_status(); Mono::delay(settle).await; let period = board::Duration::millis(u64::from(board::SENSOR_PERIOD_MS)); let mut next = Mono::now(); loop { next += period; match measure(sensor).await { Ok(raw) => { let taken_at = Mono::now(); let temp_milli_c = sht3x::temp_milli_c(raw.temp_ticks); let rh_milli_pct = sht3x::rh_milli_pct(raw.rh_ticks); defmt::debug!("{} m°C, {} m%RH", temp_milli_c, rh_milli_pct); cx.shared.reading.lock(|slot| { *slot = Some(Reading { temp_milli_c, rh_milli_pct, taken_at, }) }); cx.shared.state.lock(|s| { s.sensor_ok = true; s.ever_measured = true; s.consecutive_failures = 0; }); } Err(e) => { defmt::warn!("sensor poll failed: {}", e); let failures = cx.shared.state.lock(|s| { s.sensor_ok = false; s.consecutive_failures = s.consecutive_failures.saturating_add(1); match e { crate::sensor::Error::I2c => { s.i2c_errors = s.i2c_errors.saturating_add(1) } crate::sensor::Error::Crc => { s.sensor_crc_errors = s.sensor_crc_errors.saturating_add(1) } } s.consecutive_failures }); // Try to shake a wedged sensor loose rather than reporting a // fault forever. The reading itself stays cached and ages, // so a master watching `age_ms` sees the problem either way. if failures >= board::SENSOR_FAULT_THRESHOLD { defmt::warn!("{} consecutive failures, resetting sensor", failures); let _ = sensor.soft_reset(); Mono::delay(board::Duration::millis(u64::from(sht3x::SOFT_RESET_MAX_MS))) .await; let _ = sensor.clear_status(); } } } // Read the status register so an unexpected sensor reset shows up on // the bus instead of silently reverting the configuration. // // Failures are counted rather than discarded. Swallowing them made a // persistently unreadable status indistinguishable from a genuinely // clean one, since both leave `sensor_status` at zero. They are kept // out of the consecutive-failure count so only the measurement path // drives the fault and reset logic. match sensor.read_status() { Ok(status) => { let saw_reset = status & sht3x::status::SYSTEM_RESET_DETECTED != 0; cx.shared.state.lock(|s| { s.sensor_status = status; s.sensor_reset_seen |= saw_reset; }); if saw_reset { defmt::warn!("SHT31 reported a reset (status {=u16:#06x})", status); let _ = sensor.clear_status(); } } Err(e) => { defmt::warn!("SHT31 status read failed: {}", e); cx.shared.state.lock(|s| match e { crate::sensor::Error::I2c => s.i2c_errors = s.i2c_errors.saturating_add(1), crate::sensor::Error::Crc => { s.sensor_crc_errors = s.sensor_crc_errors.saturating_add(1) } }); } } Mono::delay_until(next).await; } } /// Start a conversion, wait out the data-sheet worst case, read it back. async fn measure( sensor: &mut Sht31, ) -> Result { sensor.start_measurement()?; // Clock stretching is disabled, so the sensor NACKs a read until the // conversion finishes. Waiting the worst case beats retry-until-ACK: it // keeps the I2C bus idle and the timing predictable. Mono::delay(board::Duration::millis(u64::from( sht3x::MEASURE_HIGH_MAX_MS, ))) .await; sensor.read_measurement() } /// Repaint the WS2812B status indicator. /// /// Green for running, blue for a measurement, red for a sensor error, with /// the most urgent condition winning: red over blue, blue over green. Read at /// a glance, a healthy node is dim green with a blue pip once a second, so a /// stalled or failing node is obvious without a serial port. /// /// Derived entirely from existing shared state — the reading's own timestamp /// already says when the last measurement landed, so nothing extra is /// published just to drive an LED. #[task(priority = 1, shared = [reading, state], local = [led])] async fn status_led(mut cx: status_led::Context) { let tick = board::Duration::millis(u64::from(board::LED_TICK_MS)); let flash = board::Duration::millis(u64::from(board::LED_MEASURING_MS)); loop { let now = Mono::now(); let measured_recently = cx .shared .reading .lock(|slot| slot.as_ref().map(|r| r.taken_at)) .and_then(|taken_at| now.checked_duration_since(taken_at)) .is_some_and(|age| age < flash); let (sensor_ok, failures) = cx .shared .state .lock(|s| (s.sensor_ok, s.consecutive_failures)); let colour = if failures > 0 || !sensor_ok { // Any failed poll shows red, not just a latched hard fault: a // sensor dropping the occasional reading is worth seeing. Rgb::ERROR } else if measured_recently { Rgb::MEASURING } else { Rgb::RUNNING }; cx.local.led.set(colour); Mono::delay(tick).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 ); } }