Initial commit: RP2040 RS485 temperature/humidity node
RTIC 2 firmware for an RP2040 acting as an RS485 slave, reading a Sensirion SHT31 over I2C and answering a custom binary protocol. Split into two crates so the protocol is testable without hardware: - wiredsensor-core: framing, CRC-16/MODBUS, command dispatch and SHT3x data-sheet math. Pure computation, no I/O, no peripherals. Covered by 42 host tests including the CRC-16/MODBUS catalogue vector, the SHT3x data-sheet CRC-8 example, and an exhaustive single-bit-corruption sweep over every byte of a frame. - wiredsensor-fw: the RTIC application, PL011 register driver and SHT31 I2C driver. Requests are answered entirely from a cached reading. An SHT31 high-repeatability conversion takes up to 15 ms, well past the turnaround a master expects, so the sensor is polled at the lowest priority and the bus path never touches I2C. RTIC's priority ceilings make "a conversion cannot delay a reply" a checked property rather than an argument. Two PL011 details drive the low-level approach, since rp2040-hal exposes neither: the receive-timeout interrupt for prompt frame delimiting, and the BUSY flag, which is the only way to know the final stop bit has left the shift register before releasing DE. The HAL performs the fiddly baud-divisor setup once, then the raw peripheral is reclaimed via free(). Note that RTIM cannot be the sole frame delimiter: it only fires while the RX FIFO is non-empty, so a frame landing exactly on the FIFO watermark is drained by the watermark interrupt and never times out. The authoritative delimiter is a monotonic timer measured from the last received byte. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "wiredsensor-core"
|
||||
description = "Pure protocol framing, dispatch and SHT4x data-sheet math for the wiredsensor RS485 node"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
defmt = { workspace = true, optional = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# Derive `defmt::Format` on the public error/data types.
|
||||
defmt = ["dep:defmt"]
|
||||
@@ -0,0 +1,76 @@
|
||||
//! CRC-16/MODBUS, used to protect every bus frame.
|
||||
//!
|
||||
//! Reflected polynomial `0xA001` (normal form `0x8005`), initial value `0xFFFF`,
|
||||
//! no final XOR, transmitted low byte first. This is the same CRC Modbus RTU
|
||||
//! uses, so off-the-shelf bus analysers validate our frames without help.
|
||||
|
||||
/// Initial CRC register value.
|
||||
pub const INIT: u16 = 0xFFFF;
|
||||
|
||||
/// Reflected generator polynomial.
|
||||
const POLY: u16 = 0xA001;
|
||||
|
||||
/// Fold one byte into a running CRC.
|
||||
#[inline]
|
||||
pub const fn update(crc: u16, byte: u8) -> u16 {
|
||||
let mut crc = crc ^ byte as u16;
|
||||
let mut bit = 0;
|
||||
while bit < 8 {
|
||||
crc = if crc & 1 != 0 {
|
||||
(crc >> 1) ^ POLY
|
||||
} else {
|
||||
crc >> 1
|
||||
};
|
||||
bit += 1;
|
||||
}
|
||||
crc
|
||||
}
|
||||
|
||||
/// CRC over a whole buffer.
|
||||
pub const fn checksum(data: &[u8]) -> u16 {
|
||||
let mut crc = INIT;
|
||||
let mut i = 0;
|
||||
while i < data.len() {
|
||||
crc = update(crc, data[i]);
|
||||
i += 1;
|
||||
}
|
||||
crc
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The canonical CRC-16/MODBUS check value for the ASCII string "123456789".
|
||||
#[test]
|
||||
fn catalogue_check_value() {
|
||||
assert_eq!(checksum(b"123456789"), 0x4B37);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_input_is_init() {
|
||||
assert_eq!(checksum(&[]), INIT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incremental_matches_bulk() {
|
||||
let data = [0x01, 0x03, 0x00, 0x6B, 0x00, 0x03];
|
||||
let mut crc = INIT;
|
||||
for &b in &data {
|
||||
crc = update(crc, b);
|
||||
}
|
||||
assert_eq!(crc, checksum(&data));
|
||||
}
|
||||
|
||||
/// Appending a frame's own CRC (little-endian) makes the CRC over the whole
|
||||
/// buffer collapse to zero. Receivers may rely on this shortcut.
|
||||
#[test]
|
||||
fn self_check_collapses_to_zero() {
|
||||
let body = [0x01u8, 0x01, 0x00];
|
||||
let crc = checksum(&body);
|
||||
let mut whole = [0u8; 5];
|
||||
whole[..3].copy_from_slice(&body);
|
||||
whole[3..].copy_from_slice(&crc.to_le_bytes());
|
||||
assert_eq!(checksum(&whole), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
//! Frame layout and validation.
|
||||
//!
|
||||
//! ```text
|
||||
//! ┌──────┬─────┬─────┬───────────────┬────────┬────────┐
|
||||
//! │ ADDR │ CMD │ LEN │ PAYLOAD[LEN] │ CRC_LO │ CRC_HI │
|
||||
//! └──────┴─────┴─────┴───────────────┴────────┴────────┘
|
||||
//! 1 1 1 0..=64 1 1
|
||||
//! ```
|
||||
//!
|
||||
//! Frames are delimited by an idle line, Modbus-RTU style, rather than by any
|
||||
//! byte value — so the payload is fully binary-transparent with no escaping.
|
||||
//! `LEN` is carried explicitly as well, which lets a receiver reject a frame on
|
||||
//! length grounds before spending time on the CRC and makes captures easy to
|
||||
//! read by eye.
|
||||
|
||||
use crate::crc;
|
||||
|
||||
/// Largest payload a frame may carry.
|
||||
pub const MAX_PAYLOAD: usize = 64;
|
||||
|
||||
/// Bytes preceding the payload: `ADDR`, `CMD`, `LEN`.
|
||||
pub const HEADER_LEN: usize = 3;
|
||||
|
||||
/// Bytes following the payload.
|
||||
pub const CRC_LEN: usize = 2;
|
||||
|
||||
/// Shortest possible frame (empty payload).
|
||||
pub const MIN_FRAME: usize = HEADER_LEN + CRC_LEN;
|
||||
|
||||
/// Longest possible frame.
|
||||
pub const MAX_FRAME: usize = HEADER_LEN + MAX_PAYLOAD + CRC_LEN;
|
||||
|
||||
/// Reserved address meaning "every node on the segment". Nodes must never reply
|
||||
/// to it, or the bus would collide.
|
||||
pub const ADDR_BROADCAST: u8 = 0x00;
|
||||
|
||||
/// Lowest assignable unit address.
|
||||
pub const ADDR_MIN: u8 = 0x01;
|
||||
|
||||
/// Highest assignable unit address (247, matching the Modbus convention).
|
||||
pub const ADDR_MAX: u8 = 0xF7;
|
||||
|
||||
/// Why a received byte sequence is not a usable frame.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
pub enum ParseError {
|
||||
/// Fewer bytes than the smallest legal frame.
|
||||
TooShort,
|
||||
/// More bytes than the largest legal frame.
|
||||
TooLong,
|
||||
/// The `LEN` field disagrees with how many bytes actually arrived.
|
||||
LengthMismatch,
|
||||
/// The trailing CRC does not cover the received bytes.
|
||||
BadCrc,
|
||||
}
|
||||
|
||||
/// Why a frame could not be built.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
pub enum EncodeError {
|
||||
/// The payload exceeds [`MAX_PAYLOAD`].
|
||||
PayloadTooLong,
|
||||
/// The destination buffer is too small for the resulting frame.
|
||||
BufferTooSmall,
|
||||
}
|
||||
|
||||
/// A validated, borrowed view of a received frame.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Frame<'a> {
|
||||
/// Destination address as it appeared on the wire.
|
||||
pub addr: u8,
|
||||
/// Command code.
|
||||
pub cmd: u8,
|
||||
/// Command payload, already length-checked.
|
||||
pub payload: &'a [u8],
|
||||
}
|
||||
|
||||
impl<'a> Frame<'a> {
|
||||
/// Validate `buf` as a complete frame.
|
||||
///
|
||||
/// `buf` must hold exactly one frame — the caller is responsible for
|
||||
/// delimiting it, which on the wire means "everything received since the
|
||||
/// last inter-frame idle gap".
|
||||
pub fn parse(buf: &'a [u8]) -> Result<Self, ParseError> {
|
||||
if buf.len() < MIN_FRAME {
|
||||
return Err(ParseError::TooShort);
|
||||
}
|
||||
if buf.len() > MAX_FRAME {
|
||||
return Err(ParseError::TooLong);
|
||||
}
|
||||
|
||||
let len = buf[2] as usize;
|
||||
if HEADER_LEN + len + CRC_LEN != buf.len() {
|
||||
return Err(ParseError::LengthMismatch);
|
||||
}
|
||||
|
||||
let (body, tail) = buf.split_at(buf.len() - CRC_LEN);
|
||||
if crc::checksum(body) != u16::from_le_bytes([tail[0], tail[1]]) {
|
||||
return Err(ParseError::BadCrc);
|
||||
}
|
||||
|
||||
Ok(Frame {
|
||||
addr: buf[0],
|
||||
cmd: buf[1],
|
||||
payload: &buf[HEADER_LEN..HEADER_LEN + len],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a frame into `out`, returning how many bytes were written.
|
||||
pub fn encode(addr: u8, cmd: u8, payload: &[u8], out: &mut [u8]) -> Result<usize, EncodeError> {
|
||||
if payload.len() > MAX_PAYLOAD {
|
||||
return Err(EncodeError::PayloadTooLong);
|
||||
}
|
||||
let total = HEADER_LEN + payload.len() + CRC_LEN;
|
||||
if out.len() < total {
|
||||
return Err(EncodeError::BufferTooSmall);
|
||||
}
|
||||
|
||||
out[0] = addr;
|
||||
out[1] = cmd;
|
||||
out[2] = payload.len() as u8;
|
||||
out[HEADER_LEN..HEADER_LEN + payload.len()].copy_from_slice(payload);
|
||||
|
||||
let crc = crc::checksum(&out[..HEADER_LEN + payload.len()]);
|
||||
out[total - 2..total].copy_from_slice(&crc.to_le_bytes());
|
||||
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// True if `addr` may be assigned to a node (i.e. is neither the broadcast
|
||||
/// address nor in the reserved range above [`ADDR_MAX`]).
|
||||
pub const fn is_valid_unit_address(addr: u8) -> bool {
|
||||
addr >= ADDR_MIN && addr <= ADDR_MAX
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn build(addr: u8, cmd: u8, payload: &[u8]) -> ([u8; MAX_FRAME], usize) {
|
||||
let mut buf = [0u8; MAX_FRAME];
|
||||
let n = encode(addr, cmd, payload, &mut buf).unwrap();
|
||||
(buf, n)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_parse_roundtrip() {
|
||||
let payload = [0xDE, 0xAD, 0xBE, 0xEF];
|
||||
let (buf, n) = build(0x2A, 0x01, &payload);
|
||||
|
||||
let f = Frame::parse(&buf[..n]).unwrap();
|
||||
assert_eq!(f.addr, 0x2A);
|
||||
assert_eq!(f.cmd, 0x01);
|
||||
assert_eq!(f.payload, &payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_payload_roundtrip() {
|
||||
let (buf, n) = build(0x01, 0x01, &[]);
|
||||
assert_eq!(n, MIN_FRAME);
|
||||
let f = Frame::parse(&buf[..n]).unwrap();
|
||||
assert!(f.payload.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_payload_roundtrip() {
|
||||
let payload = [0x5Au8; MAX_PAYLOAD];
|
||||
let (buf, n) = build(0x01, 0x04, &payload);
|
||||
assert_eq!(n, MAX_FRAME);
|
||||
assert_eq!(Frame::parse(&buf[..n]).unwrap().payload, &payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_is_binary_transparent() {
|
||||
// Bytes that would need escaping in a delimiter-based protocol.
|
||||
let payload = [0x00, 0xFF, 0x0A, 0x0D, 0x3A, 0x7E];
|
||||
let (buf, n) = build(0x01, 0x04, &payload);
|
||||
assert_eq!(Frame::parse(&buf[..n]).unwrap().payload, &payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_short_and_long() {
|
||||
assert_eq!(Frame::parse(&[]), Err(ParseError::TooShort));
|
||||
assert_eq!(Frame::parse(&[0, 0, 0, 0]), Err(ParseError::TooShort));
|
||||
assert_eq!(
|
||||
Frame::parse(&[0u8; MAX_FRAME + 1]),
|
||||
Err(ParseError::TooLong)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_length_field_mismatch() {
|
||||
let (mut buf, n) = build(0x01, 0x01, &[1, 2, 3]);
|
||||
buf[2] = 2; // claim one byte fewer than actually present
|
||||
assert_eq!(Frame::parse(&buf[..n]), Err(ParseError::LengthMismatch));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_single_bit_corruption_anywhere() {
|
||||
let payload = [0x11, 0x22, 0x33];
|
||||
let (good, n) = build(0x07, 0x02, &payload);
|
||||
|
||||
for byte in 0..n {
|
||||
for bit in 0..8 {
|
||||
let mut bad = good;
|
||||
bad[byte] ^= 1 << bit;
|
||||
// A flip in the LEN byte is caught by the length check first;
|
||||
// everything else must be caught by the CRC. Either way the
|
||||
// frame must not parse as valid.
|
||||
assert!(
|
||||
Frame::parse(&bad[..n]).is_err(),
|
||||
"flip of byte {byte} bit {bit} was accepted"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncation_is_rejected() {
|
||||
let (buf, n) = build(0x01, 0x01, &[9, 9, 9, 9]);
|
||||
for shorter in MIN_FRAME..n {
|
||||
assert!(Frame::parse(&buf[..shorter]).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_payload_is_refused() {
|
||||
let mut out = [0u8; MAX_FRAME + 8];
|
||||
assert_eq!(
|
||||
encode(1, 1, &[0u8; MAX_PAYLOAD + 1], &mut out),
|
||||
Err(EncodeError::PayloadTooLong)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_buffer_is_refused() {
|
||||
let mut out = [0u8; 4];
|
||||
assert_eq!(
|
||||
encode(1, 1, &[], &mut out),
|
||||
Err(EncodeError::BufferTooSmall)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn address_validity() {
|
||||
assert!(!is_valid_unit_address(ADDR_BROADCAST));
|
||||
assert!(is_valid_unit_address(ADDR_MIN));
|
||||
assert!(is_valid_unit_address(ADDR_MAX));
|
||||
assert!(!is_valid_unit_address(ADDR_MAX + 1));
|
||||
assert!(!is_valid_unit_address(0xFF));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! Hardware-independent core of the wiredsensor RS485 temperature/humidity node.
|
||||
//!
|
||||
//! Everything in this crate is pure computation: framing, CRCs, command dispatch
|
||||
//! and SHT3x data-sheet conversions. It performs no I/O and touches no
|
||||
//! peripherals, which keeps the protocol testable on the host with a plain
|
||||
//! `cargo test` while the firmware crate owns all register access.
|
||||
|
||||
#![cfg_attr(not(test), no_std)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
pub mod crc;
|
||||
pub mod frame;
|
||||
pub mod proto;
|
||||
pub mod sht3x;
|
||||
pub mod timing;
|
||||
@@ -0,0 +1,524 @@
|
||||
//! Application protocol: command codes, payload layouts and request dispatch.
|
||||
//!
|
||||
//! All multi-byte payload fields are **little-endian**, matching the CRC byte
|
||||
//! order on the wire and the native order of both the RP2040 and any x86/ARM
|
||||
//! host talking to it.
|
||||
//!
|
||||
//! [`dispatch`] is the whole slave-side behaviour of the node, expressed as a
|
||||
//! pure function of the received bytes plus a [`Snapshot`] of device state. The
|
||||
//! firmware supplies the snapshot and puts the returned bytes on the wire; that
|
||||
//! split is what lets the entire protocol be exercised by host unit tests.
|
||||
|
||||
use crate::frame::{self, Frame, ParseError, MAX_FRAME};
|
||||
|
||||
/// Protocol revision reported by [`cmd::READ_INFO`]. Bump on any wire-visible
|
||||
/// change.
|
||||
pub const PROTOCOL_VERSION: u8 = 1;
|
||||
|
||||
/// Command codes.
|
||||
pub mod cmd {
|
||||
/// Return the latest temperature and humidity reading.
|
||||
pub const READ_MEASUREMENT: u8 = 0x01;
|
||||
/// Return firmware version, serial numbers and uptime.
|
||||
pub const READ_INFO: u8 = 0x02;
|
||||
/// Return health flags and error counters.
|
||||
pub const READ_STATUS: u8 = 0x03;
|
||||
/// Echo the payload back unchanged; a pure link test.
|
||||
pub const PING: u8 = 0x04;
|
||||
/// Reserved for runtime address assignment. This build takes its address
|
||||
/// from a compile-time constant, so the command answers
|
||||
/// [`err::UNSUPPORTED`] rather than pretending to succeed.
|
||||
pub const SET_ADDRESS: u8 = 0x10;
|
||||
}
|
||||
|
||||
/// Set on the command byte of a reply to mark it as an error response, so a
|
||||
/// master can tell success from failure without inspecting the payload.
|
||||
pub const ERROR_FLAG: u8 = 0x80;
|
||||
|
||||
/// Error codes carried as the single payload byte of an error reply.
|
||||
pub mod err {
|
||||
/// The command code is not implemented.
|
||||
pub const ILLEGAL_COMMAND: u8 = 0x01;
|
||||
/// The command does not take a payload of the supplied length.
|
||||
pub const ILLEGAL_LENGTH: u8 = 0x02;
|
||||
/// No valid reading has been obtained since power-up.
|
||||
pub const SENSOR_UNAVAILABLE: u8 = 0x03;
|
||||
/// The sensor is present but persistently failing.
|
||||
pub const SENSOR_FAULT: u8 = 0x04;
|
||||
/// The command is recognised but disabled in this build.
|
||||
pub const UNSUPPORTED: u8 = 0x05;
|
||||
}
|
||||
|
||||
/// Bit masks for the `flags` byte of [`Status`].
|
||||
pub mod flag {
|
||||
/// The most recent sensor poll succeeded.
|
||||
pub const SENSOR_OK: u8 = 1 << 0;
|
||||
/// The cached reading is older than the node's freshness threshold.
|
||||
pub const DATA_STALE: u8 = 1 << 1;
|
||||
/// Consecutive sensor failures have exceeded the fault threshold.
|
||||
pub const SENSOR_FAULT: u8 = 1 << 2;
|
||||
/// The UART reported a receive overrun or line error since power-up.
|
||||
pub const UART_ERROR: u8 = 1 << 3;
|
||||
/// At least one valid reading has been taken since power-up.
|
||||
pub const EVER_MEASURED: u8 = 1 << 4;
|
||||
/// The sensor reported an unexpected reset, and was re-initialised.
|
||||
pub const SENSOR_RESET_SEEN: u8 = 1 << 5;
|
||||
/// The sensor's internal heater is on.
|
||||
pub const HEATER_ON: u8 = 1 << 6;
|
||||
}
|
||||
|
||||
/// Payload of a successful [`cmd::READ_MEASUREMENT`] reply.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Measurement {
|
||||
/// Temperature in milli-degrees Celsius.
|
||||
pub temp_milli_c: i32,
|
||||
/// Relative humidity in milli-percent (0..=100000).
|
||||
pub rh_milli_pct: i32,
|
||||
/// Age of this reading in milliseconds, saturating at [`u16::MAX`].
|
||||
///
|
||||
/// Reported rather than enforced: the master decides what freshness its
|
||||
/// application needs instead of having a threshold baked into the node.
|
||||
pub age_ms: u16,
|
||||
}
|
||||
|
||||
impl Measurement {
|
||||
/// Encoded length in bytes.
|
||||
pub const LEN: usize = 10;
|
||||
|
||||
/// Serialise into the wire layout.
|
||||
pub fn encode(&self, out: &mut [u8; Self::LEN]) {
|
||||
out[0..4].copy_from_slice(&self.temp_milli_c.to_le_bytes());
|
||||
out[4..8].copy_from_slice(&self.rh_milli_pct.to_le_bytes());
|
||||
out[8..10].copy_from_slice(&self.age_ms.to_le_bytes());
|
||||
}
|
||||
|
||||
/// Parse the wire layout. Provided for host-side masters and tests.
|
||||
pub fn decode(raw: &[u8; Self::LEN]) -> Self {
|
||||
Self {
|
||||
temp_milli_c: i32::from_le_bytes([raw[0], raw[1], raw[2], raw[3]]),
|
||||
rh_milli_pct: i32::from_le_bytes([raw[4], raw[5], raw[6], raw[7]]),
|
||||
age_ms: u16::from_le_bytes([raw[8], raw[9]]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Payload of a [`cmd::READ_INFO`] reply.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Info {
|
||||
/// Firmware major version.
|
||||
pub fw_major: u8,
|
||||
/// Firmware minor version.
|
||||
pub fw_minor: u8,
|
||||
/// Firmware patch version.
|
||||
pub fw_patch: u8,
|
||||
/// Value of [`PROTOCOL_VERSION`] this build implements.
|
||||
pub proto_version: u8,
|
||||
/// Board serial, fixed at build time.
|
||||
pub device_serial: u32,
|
||||
/// SHT3x factory serial number, or 0 if it could not be read.
|
||||
pub sensor_serial: u32,
|
||||
/// Seconds since power-up, saturating.
|
||||
pub uptime_s: u32,
|
||||
}
|
||||
|
||||
impl Info {
|
||||
/// Encoded length in bytes.
|
||||
pub const LEN: usize = 16;
|
||||
|
||||
/// Serialise into the wire layout.
|
||||
pub fn encode(&self, out: &mut [u8; Self::LEN]) {
|
||||
out[0] = self.fw_major;
|
||||
out[1] = self.fw_minor;
|
||||
out[2] = self.fw_patch;
|
||||
out[3] = self.proto_version;
|
||||
out[4..8].copy_from_slice(&self.device_serial.to_le_bytes());
|
||||
out[8..12].copy_from_slice(&self.sensor_serial.to_le_bytes());
|
||||
out[12..16].copy_from_slice(&self.uptime_s.to_le_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
/// Payload of a [`cmd::READ_STATUS`] reply.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct Status {
|
||||
/// Bitwise OR of the [`flag`] masks.
|
||||
pub flags: u8,
|
||||
/// Raw SHT3x status register from the last successful read.
|
||||
pub sensor_status: u16,
|
||||
/// I2C transfer failures since power-up.
|
||||
pub i2c_errors: u16,
|
||||
/// Sensor CRC-8 mismatches since power-up.
|
||||
pub sensor_crc_errors: u16,
|
||||
/// Frames rejected on length or framing grounds since power-up.
|
||||
pub frame_errors: u16,
|
||||
/// Frames rejected on CRC-16 grounds since power-up.
|
||||
pub crc_errors: u16,
|
||||
}
|
||||
|
||||
impl Status {
|
||||
/// Encoded length in bytes.
|
||||
pub const LEN: usize = 11;
|
||||
|
||||
/// Serialise into the wire layout.
|
||||
pub fn encode(&self, out: &mut [u8; Self::LEN]) {
|
||||
out[0] = self.flags;
|
||||
out[1..3].copy_from_slice(&self.sensor_status.to_le_bytes());
|
||||
out[3..5].copy_from_slice(&self.i2c_errors.to_le_bytes());
|
||||
out[5..7].copy_from_slice(&self.sensor_crc_errors.to_le_bytes());
|
||||
out[7..9].copy_from_slice(&self.frame_errors.to_le_bytes());
|
||||
out[9..11].copy_from_slice(&self.crc_errors.to_le_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything [`dispatch`] is allowed to know about the device.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Snapshot {
|
||||
/// Latest reading, or `None` if the sensor has never produced one.
|
||||
pub measurement: Option<Measurement>,
|
||||
/// Identity and version information.
|
||||
pub info: Info,
|
||||
/// Health flags and counters.
|
||||
pub status: Status,
|
||||
}
|
||||
|
||||
/// What the firmware should do with a received byte sequence.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Outcome<'o> {
|
||||
/// Put these bytes on the wire.
|
||||
Reply(&'o [u8]),
|
||||
/// A well-formed frame that is not ours to answer, or a broadcast. Keep the
|
||||
/// transmitter off the bus.
|
||||
Silent,
|
||||
/// The bytes were not a usable frame. The firmware should bump the matching
|
||||
/// counter and stay silent — replying to a frame whose address we cannot
|
||||
/// trust risks a collision with the node that was actually addressed.
|
||||
Malformed(ParseError),
|
||||
}
|
||||
|
||||
/// Handle one received frame.
|
||||
///
|
||||
/// `raw` is everything received since the last inter-frame gap; `unit` is this
|
||||
/// node's address. Returns the bytes to transmit, if any.
|
||||
pub fn dispatch<'o>(
|
||||
unit: u8,
|
||||
raw: &[u8],
|
||||
snap: &Snapshot,
|
||||
out: &'o mut [u8; MAX_FRAME],
|
||||
) -> Outcome<'o> {
|
||||
let f = match Frame::parse(raw) {
|
||||
Ok(f) => f,
|
||||
// Note we report malformed frames even when they were probably meant for
|
||||
// a different node: with a bad CRC the address byte is not trustworthy,
|
||||
// and the counters are more useful as a measure of segment quality than
|
||||
// as a per-node tally.
|
||||
Err(e) => return Outcome::Malformed(e),
|
||||
};
|
||||
|
||||
// This also covers the broadcast address, since a valid unit address is
|
||||
// never 0 — there are no write commands, so a broadcast asks nothing of us.
|
||||
if f.addr != unit {
|
||||
return Outcome::Silent;
|
||||
}
|
||||
|
||||
match f.cmd {
|
||||
cmd::READ_MEASUREMENT => {
|
||||
if !f.payload.is_empty() {
|
||||
return error(out, unit, f.cmd, err::ILLEGAL_LENGTH);
|
||||
}
|
||||
match snap.measurement {
|
||||
Some(m) => {
|
||||
let mut p = [0u8; Measurement::LEN];
|
||||
m.encode(&mut p);
|
||||
reply(out, unit, f.cmd, &p)
|
||||
}
|
||||
None if snap.status.flags & flag::SENSOR_FAULT != 0 => {
|
||||
error(out, unit, f.cmd, err::SENSOR_FAULT)
|
||||
}
|
||||
None => error(out, unit, f.cmd, err::SENSOR_UNAVAILABLE),
|
||||
}
|
||||
}
|
||||
|
||||
cmd::READ_INFO => {
|
||||
if !f.payload.is_empty() {
|
||||
return error(out, unit, f.cmd, err::ILLEGAL_LENGTH);
|
||||
}
|
||||
let mut p = [0u8; Info::LEN];
|
||||
snap.info.encode(&mut p);
|
||||
reply(out, unit, f.cmd, &p)
|
||||
}
|
||||
|
||||
cmd::READ_STATUS => {
|
||||
if !f.payload.is_empty() {
|
||||
return error(out, unit, f.cmd, err::ILLEGAL_LENGTH);
|
||||
}
|
||||
let mut p = [0u8; Status::LEN];
|
||||
snap.status.encode(&mut p);
|
||||
reply(out, unit, f.cmd, &p)
|
||||
}
|
||||
|
||||
// Echo verbatim. Proves the full round trip — framing, CRC and RS485
|
||||
// driver-enable turnaround — without involving the sensor at all.
|
||||
cmd::PING => reply(out, unit, f.cmd, f.payload),
|
||||
|
||||
cmd::SET_ADDRESS => error(out, unit, f.cmd, err::UNSUPPORTED),
|
||||
|
||||
_ => error(out, unit, f.cmd, err::ILLEGAL_COMMAND),
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode a success reply.
|
||||
fn reply<'o>(out: &'o mut [u8; MAX_FRAME], addr: u8, cmd: u8, payload: &[u8]) -> Outcome<'o> {
|
||||
let n = match frame::encode(addr, cmd, payload, out.as_mut_slice()) {
|
||||
Ok(n) => n,
|
||||
// Unreachable: every payload built above is at most MAX_PAYLOAD, and a
|
||||
// PING echo is bounded by the request it mirrors. Staying silent is the
|
||||
// safe failure mode on a shared bus.
|
||||
Err(_) => return Outcome::Silent,
|
||||
};
|
||||
Outcome::Reply(&out[..n])
|
||||
}
|
||||
|
||||
/// Encode an error reply: the command code with [`ERROR_FLAG`] set, and a
|
||||
/// one-byte reason.
|
||||
fn error<'o>(out: &'o mut [u8; MAX_FRAME], addr: u8, cmd: u8, code: u8) -> Outcome<'o> {
|
||||
reply(out, addr, cmd | ERROR_FLAG, &[code])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::frame::MAX_PAYLOAD;
|
||||
|
||||
const UNIT: u8 = 0x11;
|
||||
|
||||
fn snapshot() -> Snapshot {
|
||||
Snapshot {
|
||||
measurement: Some(Measurement {
|
||||
temp_milli_c: 23_450,
|
||||
rh_milli_pct: 41_200,
|
||||
age_ms: 137,
|
||||
}),
|
||||
info: Info {
|
||||
fw_major: 0,
|
||||
fw_minor: 1,
|
||||
fw_patch: 0,
|
||||
proto_version: PROTOCOL_VERSION,
|
||||
device_serial: 0xDEADBEEF,
|
||||
sensor_serial: 0x0102_0304,
|
||||
uptime_s: 4242,
|
||||
},
|
||||
status: Status {
|
||||
flags: flag::SENSOR_OK | flag::EVER_MEASURED,
|
||||
sensor_status: 0x8010,
|
||||
i2c_errors: 1,
|
||||
sensor_crc_errors: 2,
|
||||
frame_errors: 3,
|
||||
crc_errors: 4,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a request and run it through dispatch, returning the parsed reply.
|
||||
fn exchange(unit: u8, cmd: u8, payload: &[u8]) -> (Snapshot, [u8; MAX_FRAME], usize) {
|
||||
let mut req = [0u8; MAX_FRAME];
|
||||
let n = frame::encode(unit, cmd, payload, &mut req).unwrap();
|
||||
let snap = snapshot();
|
||||
let mut out = [0u8; MAX_FRAME];
|
||||
match dispatch(UNIT, &req[..n], &snap, &mut out) {
|
||||
Outcome::Reply(r) => {
|
||||
let len = r.len();
|
||||
let mut copy = [0u8; MAX_FRAME];
|
||||
copy[..len].copy_from_slice(r);
|
||||
(snap, copy, len)
|
||||
}
|
||||
other => panic!("expected a reply, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_measurement_roundtrips_through_the_wire() {
|
||||
let (snap, buf, n) = exchange(UNIT, cmd::READ_MEASUREMENT, &[]);
|
||||
let f = Frame::parse(&buf[..n]).unwrap();
|
||||
|
||||
assert_eq!(f.addr, UNIT);
|
||||
assert_eq!(f.cmd, cmd::READ_MEASUREMENT);
|
||||
assert_eq!(f.cmd & ERROR_FLAG, 0);
|
||||
|
||||
let mut raw = [0u8; Measurement::LEN];
|
||||
raw.copy_from_slice(f.payload);
|
||||
assert_eq!(Measurement::decode(&raw), snap.measurement.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_temperature_survives_the_encoding() {
|
||||
let m = Measurement {
|
||||
temp_milli_c: -12_345,
|
||||
rh_milli_pct: 0,
|
||||
age_ms: 0,
|
||||
};
|
||||
let mut raw = [0u8; Measurement::LEN];
|
||||
m.encode(&mut raw);
|
||||
assert_eq!(Measurement::decode(&raw), m);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_info_payload_layout() {
|
||||
let (snap, buf, n) = exchange(UNIT, cmd::READ_INFO, &[]);
|
||||
let f = Frame::parse(&buf[..n]).unwrap();
|
||||
assert_eq!(f.payload.len(), Info::LEN);
|
||||
assert_eq!(f.payload[3], PROTOCOL_VERSION);
|
||||
assert_eq!(
|
||||
u32::from_le_bytes([f.payload[4], f.payload[5], f.payload[6], f.payload[7]]),
|
||||
snap.info.device_serial
|
||||
);
|
||||
assert_eq!(
|
||||
u32::from_le_bytes([f.payload[8], f.payload[9], f.payload[10], f.payload[11]]),
|
||||
snap.info.sensor_serial
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_status_payload_layout() {
|
||||
let (snap, buf, n) = exchange(UNIT, cmd::READ_STATUS, &[]);
|
||||
let f = Frame::parse(&buf[..n]).unwrap();
|
||||
assert_eq!(f.payload.len(), Status::LEN);
|
||||
assert_eq!(f.payload[0], snap.status.flags);
|
||||
assert_eq!(
|
||||
u16::from_le_bytes([f.payload[1], f.payload[2]]),
|
||||
snap.status.sensor_status
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ping_echoes_payload_verbatim() {
|
||||
let probe = [0x00, 0xFF, 0x55, 0xAA, 0x0D, 0x0A];
|
||||
let (_, buf, n) = exchange(UNIT, cmd::PING, &probe);
|
||||
assert_eq!(Frame::parse(&buf[..n]).unwrap().payload, &probe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ping_echoes_a_maximum_payload() {
|
||||
let probe = [0x5Au8; MAX_PAYLOAD];
|
||||
let (_, buf, n) = exchange(UNIT, cmd::PING, &probe);
|
||||
assert_eq!(Frame::parse(&buf[..n]).unwrap().payload, &probe);
|
||||
}
|
||||
|
||||
/// The one behaviour that matters most on a shared bus: never transmit
|
||||
/// unless the frame was addressed to us specifically.
|
||||
#[test]
|
||||
fn stays_silent_for_other_units_and_broadcast() {
|
||||
let snap = snapshot();
|
||||
for addr in [
|
||||
frame::ADDR_BROADCAST,
|
||||
UNIT.wrapping_sub(1),
|
||||
UNIT.wrapping_add(1),
|
||||
frame::ADDR_MAX,
|
||||
] {
|
||||
let mut req = [0u8; MAX_FRAME];
|
||||
let n = frame::encode(addr, cmd::READ_MEASUREMENT, &[], &mut req).unwrap();
|
||||
let mut out = [0u8; MAX_FRAME];
|
||||
assert_eq!(
|
||||
dispatch(UNIT, &req[..n], &snap, &mut out),
|
||||
Outcome::Silent,
|
||||
"replied to address {addr:#04x}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_frames_are_reported_not_answered() {
|
||||
let snap = snapshot();
|
||||
let mut out = [0u8; MAX_FRAME];
|
||||
|
||||
assert_eq!(
|
||||
dispatch(UNIT, &[0x11, 0x01], &snap, &mut out),
|
||||
Outcome::Malformed(ParseError::TooShort)
|
||||
);
|
||||
|
||||
let mut req = [0u8; MAX_FRAME];
|
||||
let n = frame::encode(UNIT, cmd::READ_MEASUREMENT, &[], &mut req).unwrap();
|
||||
req[n - 1] ^= 0xFF; // corrupt the CRC
|
||||
assert_eq!(
|
||||
dispatch(UNIT, &req[..n], &snap, &mut out),
|
||||
Outcome::Malformed(ParseError::BadCrc)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_command_yields_illegal_command() {
|
||||
let (_, buf, n) = exchange(UNIT, 0x7E, &[]);
|
||||
let f = Frame::parse(&buf[..n]).unwrap();
|
||||
assert_eq!(f.cmd, 0x7E | ERROR_FLAG);
|
||||
assert_eq!(f.payload, &[err::ILLEGAL_COMMAND]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_address_is_refused_rather_than_ignored() {
|
||||
let (_, buf, n) = exchange(UNIT, cmd::SET_ADDRESS, &[0x22]);
|
||||
let f = Frame::parse(&buf[..n]).unwrap();
|
||||
assert_eq!(f.cmd, cmd::SET_ADDRESS | ERROR_FLAG);
|
||||
assert_eq!(f.payload, &[err::UNSUPPORTED]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readers_reject_unexpected_payloads() {
|
||||
for c in [cmd::READ_MEASUREMENT, cmd::READ_INFO, cmd::READ_STATUS] {
|
||||
let (_, buf, n) = exchange(UNIT, c, &[0xAA]);
|
||||
let f = Frame::parse(&buf[..n]).unwrap();
|
||||
assert_eq!(f.cmd, c | ERROR_FLAG, "command {c:#04x}");
|
||||
assert_eq!(f.payload, &[err::ILLEGAL_LENGTH], "command {c:#04x}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_reading_reports_unavailable() {
|
||||
let mut snap = snapshot();
|
||||
snap.measurement = None;
|
||||
snap.status.flags = 0;
|
||||
|
||||
let mut req = [0u8; MAX_FRAME];
|
||||
let n = frame::encode(UNIT, cmd::READ_MEASUREMENT, &[], &mut req).unwrap();
|
||||
let mut out = [0u8; MAX_FRAME];
|
||||
|
||||
match dispatch(UNIT, &req[..n], &snap, &mut out) {
|
||||
Outcome::Reply(r) => {
|
||||
let f = Frame::parse(r).unwrap();
|
||||
assert_eq!(f.payload, &[err::SENSOR_UNAVAILABLE]);
|
||||
}
|
||||
other => panic!("expected an error reply, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn faulted_sensor_reports_fault_instead_of_unavailable() {
|
||||
let mut snap = snapshot();
|
||||
snap.measurement = None;
|
||||
snap.status.flags = flag::SENSOR_FAULT;
|
||||
|
||||
let mut req = [0u8; MAX_FRAME];
|
||||
let n = frame::encode(UNIT, cmd::READ_MEASUREMENT, &[], &mut req).unwrap();
|
||||
let mut out = [0u8; MAX_FRAME];
|
||||
|
||||
match dispatch(UNIT, &req[..n], &snap, &mut out) {
|
||||
Outcome::Reply(r) => {
|
||||
let f = Frame::parse(r).unwrap();
|
||||
assert_eq!(f.payload, &[err::SENSOR_FAULT]);
|
||||
}
|
||||
other => panic!("expected an error reply, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every reply we emit must itself be a frame our own parser accepts.
|
||||
#[test]
|
||||
fn all_replies_are_well_formed() {
|
||||
let snap = snapshot();
|
||||
for cmd in 0x00u8..=0xFF {
|
||||
let mut req = [0u8; MAX_FRAME];
|
||||
let n = frame::encode(UNIT, cmd, &[], &mut req).unwrap();
|
||||
let mut out = [0u8; MAX_FRAME];
|
||||
if let Outcome::Reply(r) = dispatch(UNIT, &req[..n], &snap, &mut out) {
|
||||
let f = Frame::parse(r).expect("emitted an unparseable reply");
|
||||
assert_eq!(f.addr, UNIT);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
//! Pure SHT3x (SHT30/SHT31/SHT35) data-sheet logic: commands, CRC-8, conversions.
|
||||
//!
|
||||
//! No I/O happens here — the firmware's `sensor` module performs the actual I2C
|
||||
//! transfers and calls into this module to build commands and interpret replies.
|
||||
//!
|
||||
//! Note the differences from the SHT4x family, which are easy to get wrong:
|
||||
//! commands are **16-bit** rather than a single byte, and relative humidity uses
|
||||
//! a plain `100 * raw / 65535` scale with no offset term.
|
||||
|
||||
/// I2C address with the ADDR pin tied low (the usual strapping).
|
||||
pub const I2C_ADDR_DEFAULT: u8 = 0x44;
|
||||
|
||||
/// I2C address with the ADDR pin tied high, for two sensors on one bus.
|
||||
pub const I2C_ADDR_ALT: u8 = 0x45;
|
||||
|
||||
/// Number of bytes in a temperature + humidity response.
|
||||
pub const MEASUREMENT_LEN: usize = 6;
|
||||
|
||||
/// Number of bytes in a 16-bit-word-plus-CRC response (status, serial word).
|
||||
pub const WORD_LEN: usize = 3;
|
||||
|
||||
/// 16-bit command words.
|
||||
///
|
||||
/// The `*_NO_STRETCH` measurement variants deliberately disable clock
|
||||
/// stretching: we wait out the conversion with an explicit timer instead of
|
||||
/// holding SCL, which keeps the I2C bus free and avoids depending on the
|
||||
/// controller's stretch timeout.
|
||||
pub mod command {
|
||||
/// Single-shot measurement, high repeatability, clock stretching disabled.
|
||||
pub const MEASURE_HIGH_NO_STRETCH: u16 = 0x2400;
|
||||
/// Single-shot measurement, medium repeatability, clock stretching disabled.
|
||||
pub const MEASURE_MEDIUM_NO_STRETCH: u16 = 0x240B;
|
||||
/// Single-shot measurement, low repeatability, clock stretching disabled.
|
||||
pub const MEASURE_LOW_NO_STRETCH: u16 = 0x2416;
|
||||
/// Soft reset (re-initialises the sensor without a power cycle).
|
||||
pub const SOFT_RESET: u16 = 0x30A2;
|
||||
/// Read the 16-bit status register.
|
||||
pub const READ_STATUS: u16 = 0xF32D;
|
||||
/// Clear the sticky bits in the status register.
|
||||
pub const CLEAR_STATUS: u16 = 0x3041;
|
||||
/// Turn the on-die heater on (for condensation recovery only).
|
||||
pub const HEATER_ENABLE: u16 = 0x306D;
|
||||
/// Turn the on-die heater off.
|
||||
pub const HEATER_DISABLE: u16 = 0x3066;
|
||||
/// Leave periodic-acquisition mode.
|
||||
pub const BREAK: u16 = 0x3093;
|
||||
/// Read the 32-bit factory serial number (two words, each CRC-protected).
|
||||
pub const READ_SERIAL: u16 = 0x3780;
|
||||
}
|
||||
|
||||
/// Status-register bit masks.
|
||||
pub mod status {
|
||||
/// At least one alert condition is active.
|
||||
pub const ALERT_PENDING: u16 = 1 << 15;
|
||||
/// The on-die heater is currently on.
|
||||
pub const HEATER_ON: u16 = 1 << 13;
|
||||
/// Humidity tracking alert.
|
||||
pub const RH_TRACKING_ALERT: u16 = 1 << 11;
|
||||
/// Temperature tracking alert.
|
||||
pub const T_TRACKING_ALERT: u16 = 1 << 10;
|
||||
/// Sticky: the sensor saw a reset (power-on, soft or nRESET) since last cleared.
|
||||
pub const SYSTEM_RESET_DETECTED: u16 = 1 << 4;
|
||||
/// The last command could not be processed.
|
||||
pub const LAST_COMMAND_FAILED: u16 = 1 << 1;
|
||||
/// The checksum of the last written data was wrong.
|
||||
pub const WRITE_CRC_FAILED: u16 = 1 << 0;
|
||||
}
|
||||
|
||||
/// Worst-case conversion time for high repeatability (data sheet: 15 ms).
|
||||
pub const MEASURE_HIGH_MAX_MS: u32 = 16;
|
||||
/// Worst-case conversion time for medium repeatability (data sheet: 6 ms).
|
||||
pub const MEASURE_MEDIUM_MAX_MS: u32 = 7;
|
||||
/// Worst-case conversion time for low repeatability (data sheet: 4 ms).
|
||||
pub const MEASURE_LOW_MAX_MS: u32 = 5;
|
||||
/// Worst-case soft-reset time (data sheet: 1.5 ms).
|
||||
pub const SOFT_RESET_MAX_MS: u32 = 2;
|
||||
/// Worst-case power-up time (data sheet: 1.5 ms).
|
||||
pub const POWER_UP_MAX_MS: u32 = 2;
|
||||
|
||||
/// Split a 16-bit command into the wire byte order (MSB first).
|
||||
pub const fn command_bytes(cmd: u16) -> [u8; 2] {
|
||||
cmd.to_be_bytes()
|
||||
}
|
||||
|
||||
/// CRC-8 as specified by the SHT3x data sheet: polynomial `0x31`, init `0xFF`,
|
||||
/// no final XOR.
|
||||
pub const fn crc8(data: &[u8]) -> u8 {
|
||||
let mut crc: u8 = 0xFF;
|
||||
let mut i = 0;
|
||||
while i < data.len() {
|
||||
crc ^= data[i];
|
||||
let mut bit = 0;
|
||||
while bit < 8 {
|
||||
crc = if crc & 0x80 != 0 {
|
||||
(crc << 1) ^ 0x31
|
||||
} else {
|
||||
crc << 1
|
||||
};
|
||||
bit += 1;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
crc
|
||||
}
|
||||
|
||||
/// Uncalibrated sensor output, straight from the wire.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Raw {
|
||||
/// Raw temperature ticks.
|
||||
pub temp_ticks: u16,
|
||||
/// Raw relative-humidity ticks.
|
||||
pub rh_ticks: u16,
|
||||
}
|
||||
|
||||
/// Why a sensor response could not be accepted.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
pub enum DecodeError {
|
||||
/// A CRC-8 over one of the returned words did not match.
|
||||
Crc,
|
||||
}
|
||||
|
||||
/// Verify and unpack a 16-bit word followed by its CRC.
|
||||
pub fn decode_word(bytes: &[u8; WORD_LEN]) -> Result<u16, DecodeError> {
|
||||
if crc8(&bytes[..2]) != bytes[2] {
|
||||
return Err(DecodeError::Crc);
|
||||
}
|
||||
Ok(u16::from_be_bytes([bytes[0], bytes[1]]))
|
||||
}
|
||||
|
||||
/// Verify and unpack a measurement response
|
||||
/// `[T_msb, T_lsb, T_crc, RH_msb, RH_lsb, RH_crc]`.
|
||||
pub fn decode_measurement(bytes: &[u8; MEASUREMENT_LEN]) -> Result<Raw, DecodeError> {
|
||||
let temp_ticks = decode_word(&[bytes[0], bytes[1], bytes[2]])?;
|
||||
let rh_ticks = decode_word(&[bytes[3], bytes[4], bytes[5]])?;
|
||||
Ok(Raw {
|
||||
temp_ticks,
|
||||
rh_ticks,
|
||||
})
|
||||
}
|
||||
|
||||
/// Verify and unpack a serial-number response (two CRC-protected words).
|
||||
pub fn decode_serial(bytes: &[u8; MEASUREMENT_LEN]) -> Result<u32, DecodeError> {
|
||||
let hi = decode_word(&[bytes[0], bytes[1], bytes[2]])?;
|
||||
let lo = decode_word(&[bytes[3], bytes[4], bytes[5]])?;
|
||||
Ok(((hi as u32) << 16) | lo as u32)
|
||||
}
|
||||
|
||||
/// Temperature in milli-degrees Celsius.
|
||||
///
|
||||
/// Data sheet: `T[°C] = -45 + 175 * ticks / 65535`, giving -45000..=130000.
|
||||
/// Computed in 64-bit to keep the intermediate product exact on a chip with no
|
||||
/// hardware divider; this runs once per measurement so the cost is irrelevant.
|
||||
pub const fn temp_milli_c(ticks: u16) -> i32 {
|
||||
-45_000 + (175_000i64 * ticks as i64 / 65_535) as i32
|
||||
}
|
||||
|
||||
/// Relative humidity in milli-percent, clamped to the physically meaningful
|
||||
/// 0..=100000 range.
|
||||
///
|
||||
/// Data sheet: `RH[%] = 100 * ticks / 65535`. Note this has no offset term,
|
||||
/// unlike the SHT4x family.
|
||||
pub const fn rh_milli_pct(ticks: u16) -> i32 {
|
||||
let raw = (100_000i64 * ticks as i64 / 65_535) as i32;
|
||||
if raw < 0 {
|
||||
0
|
||||
} else if raw > 100_000 {
|
||||
100_000
|
||||
} else {
|
||||
raw
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The data sheet's worked CRC example: 0xBEEF checksums to 0x92.
|
||||
#[test]
|
||||
fn datasheet_crc_example() {
|
||||
assert_eq!(crc8(&[0xBE, 0xEF]), 0x92);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temperature_endpoints() {
|
||||
assert_eq!(temp_milli_c(0), -45_000);
|
||||
assert_eq!(temp_milli_c(u16::MAX), 130_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn humidity_endpoints() {
|
||||
assert_eq!(rh_milli_pct(0), 0);
|
||||
assert_eq!(rh_milli_pct(u16::MAX), 100_000);
|
||||
}
|
||||
|
||||
/// Mid-scale should land near 25 °C / 50 %RH, which is the sanity check most
|
||||
/// easily confirmed against a bench reading.
|
||||
#[test]
|
||||
fn humidity_midscale_is_fifty_percent() {
|
||||
assert_eq!(rh_milli_pct(0x8000), 50_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temperature_midscale_is_about_425_deci() {
|
||||
// -45 + 175/2 = 42.5 °C
|
||||
assert_eq!(temp_milli_c(0x8000), 42_501);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measurement_roundtrip() {
|
||||
let t: u16 = 0x6666;
|
||||
let h: u16 = 0x8000;
|
||||
let mut buf = [0u8; MEASUREMENT_LEN];
|
||||
buf[..2].copy_from_slice(&t.to_be_bytes());
|
||||
buf[2] = crc8(&buf[..2]);
|
||||
buf[3..5].copy_from_slice(&h.to_be_bytes());
|
||||
buf[5] = crc8(&buf[3..5]);
|
||||
|
||||
let raw = decode_measurement(&buf).unwrap();
|
||||
assert_eq!(raw.temp_ticks, t);
|
||||
assert_eq!(raw.rh_ticks, h);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupted_measurement_is_rejected() {
|
||||
let mut buf = [0u8; MEASUREMENT_LEN];
|
||||
buf[2] = crc8(&buf[..2]);
|
||||
buf[5] = crc8(&buf[3..5]);
|
||||
assert!(decode_measurement(&buf).is_ok());
|
||||
|
||||
buf[1] ^= 0x01; // flip a data bit without fixing the CRC
|
||||
assert_eq!(decode_measurement(&buf), Err(DecodeError::Crc));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_go_out_msb_first() {
|
||||
assert_eq!(
|
||||
command_bytes(command::MEASURE_HIGH_NO_STRETCH),
|
||||
[0x24, 0x00]
|
||||
);
|
||||
assert_eq!(command_bytes(command::SOFT_RESET), [0x30, 0xA2]);
|
||||
assert_eq!(command_bytes(command::READ_STATUS), [0xF3, 0x2D]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Wire timing derived from the line rate.
|
||||
//!
|
||||
//! Kept here rather than in the firmware so the arithmetic — which decides
|
||||
//! whether frames are delimited correctly and whether the transceiver is
|
||||
//! released at the right moment — is covered by host tests.
|
||||
|
||||
/// Bits on the wire per character: 1 start + 8 data + 1 stop, i.e. 8N1.
|
||||
pub const BITS_PER_CHAR: u32 = 10;
|
||||
|
||||
/// Idle time that delimits one frame from the next, in microseconds.
|
||||
///
|
||||
/// Modbus specifies 3.5 character times, but pins the value at a fixed 1750 µs
|
||||
/// above 19200 baud, where 3.5 characters shrink to something a busy slave
|
||||
/// cannot reliably measure. We follow the same rule so that standard bus
|
||||
/// analysers and masters agree with us about where frames begin and end.
|
||||
pub const fn inter_frame_gap_us(baud: u32) -> u32 {
|
||||
if baud > 19_200 {
|
||||
1_750
|
||||
} else {
|
||||
// 3.5 * BITS_PER_CHAR / baud, kept in integer arithmetic.
|
||||
(7 * BITS_PER_CHAR * 1_000_000) / (2 * baud)
|
||||
}
|
||||
}
|
||||
|
||||
/// Time to clock out one character, in microseconds.
|
||||
pub const fn char_time_us(baud: u32) -> u32 {
|
||||
(BITS_PER_CHAR * 1_000_000) / baud
|
||||
}
|
||||
|
||||
/// Time to clock out `chars` characters, in microseconds.
|
||||
pub const fn tx_time_us(chars: usize, baud: u32) -> u32 {
|
||||
chars as u32 * char_time_us(baud)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn gap_is_three_and_a_half_characters_at_low_rates() {
|
||||
// 3.5 * 10 / 9600 s = 3645.8 µs
|
||||
assert_eq!(inter_frame_gap_us(9_600), 3_645);
|
||||
// 3.5 * 10 / 19200 s = 1822.9 µs
|
||||
assert_eq!(inter_frame_gap_us(19_200), 1_822);
|
||||
assert_eq!(inter_frame_gap_us(4_800), 7_291);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gap_is_pinned_above_19200() {
|
||||
assert_eq!(inter_frame_gap_us(38_400), 1_750);
|
||||
assert_eq!(inter_frame_gap_us(115_200), 1_750);
|
||||
}
|
||||
|
||||
/// The gap must always exceed a single character time, or a frame would be
|
||||
/// declared complete between two back-to-back bytes.
|
||||
#[test]
|
||||
fn gap_always_exceeds_one_character() {
|
||||
for baud in [1_200, 2_400, 4_800, 9_600, 19_200, 38_400, 57_600, 115_200] {
|
||||
assert!(
|
||||
inter_frame_gap_us(baud) > char_time_us(baud),
|
||||
"gap {} is not longer than a character {} at {} baud",
|
||||
inter_frame_gap_us(baud),
|
||||
char_time_us(baud),
|
||||
baud
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_time_tracks_baud() {
|
||||
assert_eq!(char_time_us(9_600), 1_041);
|
||||
assert_eq!(char_time_us(19_200), 520);
|
||||
assert_eq!(char_time_us(115_200), 86);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transmission_time_scales_linearly() {
|
||||
assert_eq!(tx_time_us(0, 19_200), 0);
|
||||
assert_eq!(tx_time_us(1, 19_200), char_time_us(19_200));
|
||||
assert_eq!(tx_time_us(21, 19_200), 21 * 520);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user