Speak Modbus RTU instead of a custom command protocol

ESPHome only has built-in support for Modbus RTU over RS485, so the
application layer moves to it. The transport already conformed: the CRC
was CRC-16/MODBUS, the line rate 19200 8N1, the delimiter a 3.5-character
idle gap pinned at 1750 us above 19200, and the address space 1..247 with
broadcast never answered. Only the bytes between the address and the CRC
change, and no external crate is needed for a read-only server.

frame.rs drops the explicit LEN byte, which RTU does not have — a
request's length is implied by its function code. The parser gets simpler
and MAX_FRAME grows to the RTU limit of 256. The CRC is now the only
integrity check there is, so a truncated frame fails it rather than a
length comparison; a test asserts every single-bit corruption is still
caught, and another pins our encoding against the published example
01 03 00 6B 00 03 74 17.

proto.rs becomes a Modbus server: function codes 0x03 and 0x04 over one
19-register table, 0x08 sub-function 0x0000 as the link test that
replaces PING, and the four standard exception codes. Registers are
big-endian with 32-bit values high word first, which is what masters and
ESPHome's S_DWORD assume. Snapshot::registers renders the whole map and
reads slice it, so a range read cannot disagree with a single-register
read of the same address — the alternative, assembling only the requested
registers per read, has no such guarantee.

Both read function codes serve the same table. That is not only for
masters that implement just 0x03: a read overlapping the measurement
block is refused with SERVER_DEVICE_FAILURE when the sensor has never
produced a reading, and ESPHome coalesces adjacent registers of one type
into a single command, so diagnostic entities merged into that command
would go unavailable along with the measurement they were meant to
explain. Requesting them under the other function code puts them in their
own command. The generated ESPHome config does this.

The firmware barely changes, because dispatch's signature does not: flags
widen to u16, buffers follow MAX_FRAME, and comments name registers
rather than commands. Now ~35 KiB flash and ~2.6 KiB RAM.

tools/wiredsensor.py is rewritten and grows from 15 checks to 21, adding
agreement between the two function codes, sub-range consistency, and a
per-code assertion for every exception. It stays a hand-written Modbus
implementation rather than moving to pymodbus: half these checks inject
deliberately malformed frames, and a conforming client library exists
precisely to make those unconstructable. It also keeps the wire format
independent of wiredsensor-core, so a shared bug cannot cancel itself
out.

Not yet exercised on hardware — the host tests and register-map
cross-checks pass, but the timing-dependent behaviour needs
`wiredsensor.py test` against a real bus.

This replaces the old protocol rather than joining it; command codes
0x01..0x04 are all valid Modbus function codes, so the two cannot share a
segment. PROTOCOL_VERSION is 2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Walter
2026-07-29 17:04:21 +02:00
parent f2d06278af
commit 5b9c09df3b
10 changed files with 1293 additions and 628 deletions
+88 -84
View File
@@ -1,34 +1,41 @@
//! Frame layout and validation.
//! Modbus RTU ADU layout and validation.
//!
//! ```text
//! ┌──────┬─────┬─────┬───────────────┬────────┬────────┐
//! │ ADDR │ CMD │ LEN │ PAYLOAD[LEN] │ CRC_LO │ CRC_HI │
//! └──────┴─────┴─────┴───────────────┴────────┴────────┘
//! 1 1 1 0..=64 1 1
//! ┌──────┬──────────────────┬────────┬────────┐
//! │ ADDR │ FC │ DATA │ CRC_LO │ CRC_HI │
//! └──────┴──────────────────┴────────┴────────┘
//! 1 1 0..=252 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.
//! Frames are delimited by an idle line rather than by any byte value, so the
//! data field is fully binary-transparent with no escaping.
//!
//! Note what is *absent*: there is no explicit length field. In Modbus RTU the
//! length of a request is implied by its function code, and the length of a
//! response by the byte-count field the function code prescribes. A receiver
//! therefore cannot reject a frame on length grounds before checking the CRC —
//! the idle gap decides where the frame ends and the CRC decides whether it
//! survived. A frame that arrives one byte short simply fails the CRC and is
//! discarded, and the master retries.
use crate::crc;
/// Largest payload a frame may carry.
pub const MAX_PAYLOAD: usize = 64;
/// Largest data field a frame may carry: the 256-byte RTU limit less `ADDR`,
/// `FC` and the CRC.
pub const MAX_DATA: usize = MAX_FRAME - HEADER_LEN - CRC_LEN;
/// Bytes preceding the payload: `ADDR`, `CMD`, `LEN`.
pub const HEADER_LEN: usize = 3;
/// Bytes preceding the data field: `ADDR`, `FC`.
pub const HEADER_LEN: usize = 2;
/// Bytes following the payload.
/// Bytes following the data field.
pub const CRC_LEN: usize = 2;
/// Shortest possible frame (empty payload).
/// Shortest possible frame: address, function code and CRC.
pub const MIN_FRAME: usize = HEADER_LEN + CRC_LEN;
/// Longest possible frame.
pub const MAX_FRAME: usize = HEADER_LEN + MAX_PAYLOAD + CRC_LEN;
/// Longest possible frame, fixed by the Modbus RTU specification at 256 bytes
/// regardless of what any particular function code needs.
pub const MAX_FRAME: usize = 256;
/// Reserved address meaning "every node on the segment". Nodes must never reply
/// to it, or the bus would collide.
@@ -37,7 +44,7 @@ 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).
/// Highest assignable unit address (247, per the Modbus specification).
pub const ADDR_MAX: u8 = 0xF7;
/// Why a received byte sequence is not a usable frame.
@@ -48,8 +55,6 @@ pub enum ParseError {
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,
}
@@ -58,8 +63,8 @@ pub enum ParseError {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
pub enum EncodeError {
/// The payload exceeds [`MAX_PAYLOAD`].
PayloadTooLong,
/// The data field exceeds [`MAX_DATA`].
DataTooLong,
/// The destination buffer is too small for the resulting frame.
BufferTooSmall,
}
@@ -69,10 +74,11 @@ pub enum EncodeError {
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],
/// Function code.
pub fc: u8,
/// Everything between the function code and the CRC. Interpreting it is the
/// function code's business; this layer only guarantees it arrived intact.
pub data: &'a [u8],
}
impl<'a> Frame<'a> {
@@ -89,11 +95,6 @@ impl<'a> Frame<'a> {
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);
@@ -101,28 +102,27 @@ impl<'a> Frame<'a> {
Ok(Frame {
addr: buf[0],
cmd: buf[1],
payload: &buf[HEADER_LEN..HEADER_LEN + len],
fc: buf[1],
data: &body[HEADER_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);
pub fn encode(addr: u8, fc: u8, data: &[u8], out: &mut [u8]) -> Result<usize, EncodeError> {
if data.len() > MAX_DATA {
return Err(EncodeError::DataTooLong);
}
let total = HEADER_LEN + payload.len() + CRC_LEN;
let total = HEADER_LEN + data.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);
out[1] = fc;
out[HEADER_LEN..HEADER_LEN + data.len()].copy_from_slice(data);
let crc = crc::checksum(&out[..HEADER_LEN + payload.len()]);
let crc = crc::checksum(&out[..HEADER_LEN + data.len()]);
out[total - 2..total].copy_from_slice(&crc.to_le_bytes());
Ok(total)
@@ -138,78 +138,81 @@ pub const fn is_valid_unit_address(addr: u8) -> bool {
mod tests {
use super::*;
fn build(addr: u8, cmd: u8, payload: &[u8]) -> ([u8; MAX_FRAME], usize) {
fn build(addr: u8, fc: u8, data: &[u8]) -> ([u8; MAX_FRAME], usize) {
let mut buf = [0u8; MAX_FRAME];
let n = encode(addr, cmd, payload, &mut buf).unwrap();
let n = encode(addr, fc, data, &mut buf).unwrap();
(buf, n)
}
#[test]
fn encode_parse_roundtrip() {
let payload = [0xDE, 0xAD, 0xBE, 0xEF];
let (buf, n) = build(0x2A, 0x01, &payload);
let data = [0x00, 0x00, 0x00, 0x02];
let (buf, n) = build(0x2A, 0x04, &data);
let f = Frame::parse(&buf[..n]).unwrap();
assert_eq!(f.addr, 0x2A);
assert_eq!(f.cmd, 0x01);
assert_eq!(f.payload, &payload);
assert_eq!(f.fc, 0x04);
assert_eq!(f.data, &data);
}
#[test]
fn empty_payload_roundtrip() {
let (buf, n) = build(0x01, 0x01, &[]);
fn empty_data_roundtrip() {
let (buf, n) = build(0x01, 0x03, &[]);
assert_eq!(n, MIN_FRAME);
let f = Frame::parse(&buf[..n]).unwrap();
assert!(f.payload.is_empty());
assert!(f.data.is_empty());
}
#[test]
fn max_payload_roundtrip() {
let payload = [0x5Au8; MAX_PAYLOAD];
let (buf, n) = build(0x01, 0x04, &payload);
fn max_data_roundtrip() {
let data = [0x5Au8; MAX_DATA];
let (buf, n) = build(0x01, 0x04, &data);
assert_eq!(n, MAX_FRAME);
assert_eq!(Frame::parse(&buf[..n]).unwrap().payload, &payload);
assert_eq!(Frame::parse(&buf[..n]).unwrap().data, &data);
}
#[test]
fn payload_is_binary_transparent() {
fn data_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);
let data = [0x00, 0xFF, 0x0A, 0x0D, 0x3A, 0x7E];
let (buf, n) = build(0x01, 0x08, &data);
assert_eq!(Frame::parse(&buf[..n]).unwrap().data, &data);
}
/// The canonical published RTU example: unit 1, read holding registers,
/// start 0x006B, count 3, which every Modbus reference renders as
/// `01 03 00 6B 00 03 74 17`. Anchors our byte order and CRC placement
/// against an external reference rather than only against ourselves.
#[test]
fn matches_a_published_rtu_example() {
let (buf, n) = build(0x01, 0x03, &[0x00, 0x6B, 0x00, 0x03]);
assert_eq!(&buf[..n], &[0x01, 0x03, 0x00, 0x6B, 0x00, 0x03, 0x74, 0x17]);
}
#[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(&[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));
}
/// With no length field the CRC is the only integrity check there is, so it
/// has to catch every single-bit corruption on its own.
#[test]
fn rejects_single_bit_corruption_anywhere() {
let payload = [0x11, 0x22, 0x33];
let (good, n) = build(0x07, 0x02, &payload);
let data = [0x11, 0x22, 0x33];
let (good, n) = build(0x07, 0x03, &data);
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(),
assert_eq!(
Frame::parse(&bad[..n]),
Err(ParseError::BadCrc),
"flip of byte {byte} bit {bit} was accepted"
);
}
@@ -218,28 +221,29 @@ mod tests {
#[test]
fn truncation_is_rejected() {
let (buf, n) = build(0x01, 0x01, &[9, 9, 9, 9]);
let (buf, n) = build(0x01, 0x03, &[0x00, 0x00, 0x00, 0x13]);
for shorter in MIN_FRAME..n {
assert!(Frame::parse(&buf[..shorter]).is_err());
assert_eq!(
Frame::parse(&buf[..shorter]),
Err(ParseError::BadCrc),
"a frame truncated to {shorter} bytes was accepted"
);
}
}
#[test]
fn oversized_payload_is_refused() {
fn oversized_data_is_refused() {
let mut out = [0u8; MAX_FRAME + 8];
assert_eq!(
encode(1, 1, &[0u8; MAX_PAYLOAD + 1], &mut out),
Err(EncodeError::PayloadTooLong)
encode(1, 3, &[0u8; MAX_DATA + 1], &mut out),
Err(EncodeError::DataTooLong)
);
}
#[test]
fn small_buffer_is_refused() {
let mut out = [0u8; 4];
assert_eq!(
encode(1, 1, &[], &mut out),
Err(EncodeError::BufferTooSmall)
);
let mut out = [0u8; 3];
assert_eq!(encode(1, 3, &[], &mut out), Err(EncodeError::BufferTooSmall));
}
#[test]
+2 -2
View File
@@ -1,7 +1,7 @@
//! 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
//! Everything in this crate is pure computation: Modbus RTU framing, CRCs, the
//! register map 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.
+512 -270
View File
@@ -1,73 +1,161 @@
//! Application protocol: command codes, payload layouts and request dispatch.
//! Modbus RTU server: function codes, the register map 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.
//! The node is a Modbus **server** (slave) speaking RTU over RS485. It answers
//! two function codes — `0x03` Read Holding Registers and `0x04` Read Input
//! Registers — over one shared register table, plus `0x08` Read Diagnostics as a
//! link test. Everything the device knows is exposed as 16-bit registers, which
//! is what makes it readable by any off-the-shelf master, ESPHome's
//! `modbus_controller` among them.
//!
//! [`dispatch`] is the whole slave-side behaviour of the node, expressed as a
//! Register values are **big-endian**, high byte first, as the Modbus
//! specification requires. Quantities wider than 16 bits occupy two consecutive
//! registers, **high word first** — the order masters conventionally assume for
//! a 32-bit value, and what ESPHome calls `S_DWORD` / `U_DWORD`.
//!
//! Measured values and identity live in one flat table rather than being split
//! between input and holding space. Serving both function codes from it costs one
//! extra match arm and means a master that only implements `0x03` still works,
//! which several do.
//!
//! [`dispatch`] is the whole server-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;
/// Register-map revision, reported by [`reg::FW_PATCH_PROTO`]. Bump on any
/// wire-visible change.
///
/// Version 1 was a custom command protocol; version 2 is Modbus RTU. The two
/// cannot coexist on a segment — the old command codes `0x01`..`0x04` are all
/// valid Modbus function codes, so a register read would be indistinguishable
/// from a legacy command.
pub const PROTOCOL_VERSION: u8 = 2;
/// 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;
/// Function codes this server implements.
pub mod fc {
/// Read Holding Registers.
pub const READ_HOLDING_REGISTERS: u8 = 0x03;
/// Read Input Registers. Served from the same table as `0x03`.
pub const READ_INPUT_REGISTERS: u8 = 0x04;
/// Read Diagnostics — see [`super::DIAG_RETURN_QUERY_DATA`].
pub const DIAGNOSTIC: u8 = 0x08;
}
/// 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;
/// Set on the function code of a response to mark it an exception, so a master
/// can tell success from failure without inspecting the data field.
pub const EXCEPTION_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;
/// Modbus exception codes, carried as the single data byte of an exception
/// response.
pub mod exception {
/// The function code is not implemented here.
pub const ILLEGAL_FUNCTION: u8 = 0x01;
/// The requested register range falls outside the map.
pub const ILLEGAL_DATA_ADDRESS: u8 = 0x02;
/// A field of the request is not a value this function accepts.
pub const ILLEGAL_DATA_VALUE: u8 = 0x03;
/// The device cannot answer: no valid reading has ever been taken, or the
/// sensor is persistently failing.
pub const SERVER_DEVICE_FAILURE: u8 = 0x04;
}
/// Bit masks for the `flags` byte of [`Status`].
/// The one [`fc::DIAGNOSTIC`] sub-function implemented: echo the data field back
/// unchanged.
///
/// This is the intended first bring-up step, and the equivalent of the `PING`
/// command the pre-Modbus protocol had. It exercises framing, CRC and the RS485
/// driver-enable turnaround without involving the sensor or the register map at
/// all.
pub const DIAG_RETURN_QUERY_DATA: u16 = 0x0000;
/// Most registers a single read may ask for: `(256 - 5) / 2`, rounded down, as
/// the specification fixes it.
///
/// Larger than the whole map on purpose. The limit is a property of the RTU
/// frame size, and a master asking for 200 registers has made a different
/// mistake from one asking for 5 registers past the end — so the two get
/// different exception codes.
pub const MAX_READ_REGISTERS: u16 = 125;
/// The register map.
///
/// Addresses are stable: append to the end rather than renumbering, or every
/// deployed master's configuration breaks at once.
pub mod reg {
/// Temperature in milli-degrees Celsius, signed, 2 registers.
pub const TEMP_MILLI_C: u16 = 0x0000;
/// Relative humidity in milli-percent, signed, 2 registers.
pub const RH_MILLI_PCT: u16 = 0x0002;
/// Age of the cached reading in milliseconds, saturating at [`u16::MAX`].
pub const AGE_MS: u16 = 0x0004;
/// Health flags; see the [`flag`](super::flag) masks.
pub const FLAGS: u16 = 0x0005;
/// Raw SHT3x status register from the last successful read.
pub const SENSOR_STATUS: u16 = 0x0006;
/// I2C transfer failures since power-up.
pub const I2C_ERRORS: u16 = 0x0007;
/// Sensor CRC-8 mismatches since power-up.
pub const SENSOR_CRC_ERRORS: u16 = 0x0008;
/// Frames rejected on length or framing grounds since power-up.
pub const FRAME_ERRORS: u16 = 0x0009;
/// Frames rejected on CRC-16 grounds since power-up.
pub const CRC_ERRORS: u16 = 0x000A;
/// Firmware version: major in the high byte, minor in the low byte.
pub const FW_MAJOR_MINOR: u16 = 0x000B;
/// Firmware patch in the high byte, [`PROTOCOL_VERSION`](super::PROTOCOL_VERSION)
/// in the low byte.
pub const FW_PATCH_PROTO: u16 = 0x000C;
/// Board serial, fixed at build time, 2 registers.
pub const DEVICE_SERIAL: u16 = 0x000D;
/// SHT3x factory serial number, 2 registers, zero if it could not be read.
pub const SENSOR_SERIAL: u16 = 0x000F;
/// Seconds since power-up, saturating, 2 registers.
pub const UPTIME_S: u16 = 0x0011;
/// One past the last valid register address.
pub const COUNT: u16 = 0x0013;
/// One past the last register whose value depends on a reading existing.
///
/// A read touching `0x0000..MEASUREMENT_END` fails with
/// [`SERVER_DEVICE_FAILURE`](super::exception::SERVER_DEVICE_FAILURE) when
/// the sensor has never produced a reading. Reads confined to the diagnostic
/// and identity registers above it still succeed, which is deliberate: those
/// are exactly the registers you need to work out *why* the sensor is
/// silent. Reporting a zeroed measurement as valid would be the worse
/// failure — a master cannot tell it from a real 0 °C.
///
/// Note the interaction with masters that coalesce adjacent registers into
/// one request, ESPHome among them: a merged read that happens to span this
/// boundary is refused as a whole. Serving both `0x03` and `0x04` from one
/// table is what gives such a master a way out — the same registers under
/// the other function code land in a separate request.
pub const MEASUREMENT_END: u16 = 0x0005;
}
/// Bit masks for [`reg::FLAGS`].
pub mod flag {
/// The most recent sensor poll succeeded.
pub const SENSOR_OK: u8 = 1 << 0;
pub const SENSOR_OK: u16 = 1 << 0;
/// The cached reading is older than the node's freshness threshold.
pub const DATA_STALE: u8 = 1 << 1;
pub const DATA_STALE: u16 = 1 << 1;
/// Consecutive sensor failures have exceeded the fault threshold.
pub const SENSOR_FAULT: u8 = 1 << 2;
pub const SENSOR_FAULT: u16 = 1 << 2;
/// The UART reported a receive overrun or line error since power-up.
pub const UART_ERROR: u8 = 1 << 3;
pub const UART_ERROR: u16 = 1 << 3;
/// At least one valid reading has been taken since power-up.
pub const EVER_MEASURED: u8 = 1 << 4;
pub const EVER_MEASURED: u16 = 1 << 4;
/// The sensor reported an unexpected reset, and was re-initialised.
pub const SENSOR_RESET_SEEN: u8 = 1 << 5;
pub const SENSOR_RESET_SEEN: u16 = 1 << 5;
/// The sensor's internal heater is on.
pub const HEATER_ON: u8 = 1 << 6;
pub const HEATER_ON: u16 = 1 << 6;
}
/// Payload of a successful [`cmd::READ_MEASUREMENT`] reply.
/// The latest reading, as it appears in the measurement registers.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Measurement {
/// Temperature in milli-degrees Celsius.
@@ -82,27 +170,18 @@ pub struct Measurement {
}
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 {
/// Read a measurement back out of a register image. For host-side masters
/// and tests.
pub fn from_registers(regs: &[u16; 5]) -> 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]]),
temp_milli_c: get_u32(regs, reg::TEMP_MILLI_C) as i32,
rh_milli_pct: get_u32(regs, reg::RH_MILLI_PCT) as i32,
age_ms: regs[reg::AGE_MS as usize],
}
}
}
/// Payload of a [`cmd::READ_INFO`] reply.
/// Identity and version, as it appears in the info registers.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Info {
/// Firmware major version.
@@ -121,27 +200,11 @@ pub struct Info {
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.
/// Health flags and counters, as they appear in the diagnostic registers.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Status {
/// Bitwise OR of the [`flag`] masks.
pub flags: u8,
pub flags: u16,
/// Raw SHT3x status register from the last successful read.
pub sensor_status: u16,
/// I2C transfer failures since power-up.
@@ -154,21 +217,6 @@ pub struct Status {
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 {
@@ -180,6 +228,54 @@ pub struct Snapshot {
pub status: Status,
}
impl Snapshot {
/// Render the whole register map.
///
/// Built in full and then sliced, rather than each read assembling only the
/// registers it was asked for. 38 bytes of stack buys the guarantee that a
/// range read cannot disagree with a single-register read of the same
/// address.
pub fn registers(&self) -> [u16; reg::COUNT as usize] {
let mut r = [0u16; reg::COUNT as usize];
// Left at zero when there is no reading. A read that would expose them
// is refused in `read_registers`, so the zeros are never observable.
if let Some(m) = self.measurement {
put_u32(&mut r, reg::TEMP_MILLI_C, m.temp_milli_c as u32);
put_u32(&mut r, reg::RH_MILLI_PCT, m.rh_milli_pct as u32);
r[reg::AGE_MS as usize] = m.age_ms;
}
r[reg::FLAGS as usize] = self.status.flags;
r[reg::SENSOR_STATUS as usize] = self.status.sensor_status;
r[reg::I2C_ERRORS as usize] = self.status.i2c_errors;
r[reg::SENSOR_CRC_ERRORS as usize] = self.status.sensor_crc_errors;
r[reg::FRAME_ERRORS as usize] = self.status.frame_errors;
r[reg::CRC_ERRORS as usize] = self.status.crc_errors;
r[reg::FW_MAJOR_MINOR as usize] =
(u16::from(self.info.fw_major) << 8) | u16::from(self.info.fw_minor);
r[reg::FW_PATCH_PROTO as usize] =
(u16::from(self.info.fw_patch) << 8) | u16::from(self.info.proto_version);
put_u32(&mut r, reg::DEVICE_SERIAL, self.info.device_serial);
put_u32(&mut r, reg::SENSOR_SERIAL, self.info.sensor_serial);
put_u32(&mut r, reg::UPTIME_S, self.info.uptime_s);
r
}
}
/// Write a 32-bit value across two registers, high word first.
fn put_u32(regs: &mut [u16], at: u16, v: u32) {
regs[at as usize] = (v >> 16) as u16;
regs[at as usize + 1] = v as u16;
}
/// Read back what [`put_u32`] wrote.
fn get_u32(regs: &[u16], at: u16) -> u32 {
(u32::from(regs[at as usize]) << 16) | u32::from(regs[at as usize + 1])
}
/// What the firmware should do with a received byte sequence.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Outcome<'o> {
@@ -190,7 +286,8 @@ pub enum Outcome<'o> {
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.
/// trust risks a collision with the node that was actually addressed, and
/// the specification requires silence here.
Malformed(ParseError),
}
@@ -214,79 +311,104 @@ pub fn dispatch<'o>(
};
// 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.
// never 0 — the map is read-only, 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),
}
match f.fc {
fc::READ_HOLDING_REGISTERS | fc::READ_INPUT_REGISTERS => {
read_registers(out, unit, f.fc, f.data, snap)
}
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),
fc::DIAGNOSTIC => diagnostic(out, unit, f.data),
_ => exception(out, unit, f.fc, exception::ILLEGAL_FUNCTION),
}
}
/// 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()) {
/// Serve `0x03` and `0x04`: `start` and `count`, both big-endian, in and a
/// byte-counted block of registers out.
fn read_registers<'o>(
out: &'o mut [u8; MAX_FRAME],
unit: u8,
fc: u8,
data: &[u8],
snap: &Snapshot,
) -> Outcome<'o> {
if data.len() != 4 {
return exception(out, unit, fc, exception::ILLEGAL_DATA_VALUE);
}
let start = u16::from_be_bytes([data[0], data[1]]);
let count = u16::from_be_bytes([data[2], data[3]]);
// Order matters. An impossible count is a malformed request whatever the
// map looks like, so it is rejected before the range check — otherwise
// `start + count` overflowing the map would mask it as a bad address.
if count == 0 || count > MAX_READ_REGISTERS {
return exception(out, unit, fc, exception::ILLEGAL_DATA_VALUE);
}
// Widened so a start near 0xFFFF cannot wrap into a range that looks valid.
if u32::from(start) + u32::from(count) > u32::from(reg::COUNT) {
return exception(out, unit, fc, exception::ILLEGAL_DATA_ADDRESS);
}
if start < reg::MEASUREMENT_END && snap.measurement.is_none() {
return exception(out, unit, fc, exception::SERVER_DEVICE_FAILURE);
}
let regs = snap.registers();
// The checks above bound `count` by `reg::COUNT`, so the whole map plus its
// byte-count byte is always enough room.
let mut pdu = [0u8; 1 + 2 * reg::COUNT as usize];
let byte_count = count as usize * 2;
pdu[0] = byte_count as u8;
for i in 0..count as usize {
let v = regs[start as usize + i];
pdu[1 + 2 * i..3 + 2 * i].copy_from_slice(&v.to_be_bytes());
}
reply(out, unit, fc, &pdu[..1 + byte_count])
}
/// Serve `0x08`: echo the data field, sub-function included.
///
/// The specification's Return Query Data carries exactly two data bytes, but any
/// length is echoed here. A longer echo is a strictly better link test — it
/// reaches frame lengths past the UART's FIFO watermark, where the frame-gap
/// logic behaves differently — and no real master sends anything else.
fn diagnostic<'o>(out: &'o mut [u8; MAX_FRAME], unit: u8, data: &[u8]) -> Outcome<'o> {
if data.len() < 2 {
return exception(out, unit, fc::DIAGNOSTIC, exception::ILLEGAL_DATA_VALUE);
}
if u16::from_be_bytes([data[0], data[1]]) != DIAG_RETURN_QUERY_DATA {
// The function code is implemented but this sub-function is not, which
// from the master's side is the same fact: the diagnostic is unavailable
// here.
return exception(out, unit, fc::DIAGNOSTIC, exception::ILLEGAL_FUNCTION);
}
reply(out, unit, fc::DIAGNOSTIC, data)
}
/// Encode a success response.
fn reply<'o>(out: &'o mut [u8; MAX_FRAME], addr: u8, fc: u8, data: &[u8]) -> Outcome<'o> {
let n = match frame::encode(addr, fc, data, 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.
// Unreachable: every response built above is at most MAX_DATA, and a
// diagnostic 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])
/// Encode an exception response: the function code with [`EXCEPTION_FLAG`] set,
/// and a one-byte reason.
fn exception<'o>(out: &'o mut [u8; MAX_FRAME], addr: u8, fc: u8, code: u8) -> Outcome<'o> {
reply(out, addr, fc | EXCEPTION_FLAG, &[code])
}
#[cfg(test)]
mod tests {
use super::*;
use crate::frame::MAX_PAYLOAD;
const UNIT: u8 = 0x11;
@@ -317,91 +439,235 @@ mod tests {
}
}
/// 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) {
/// Build a request and run it through dispatch, returning the raw response.
fn exchange_with(snap: &Snapshot, fc: u8, data: &[u8]) -> ([u8; MAX_FRAME], usize) {
let mut req = [0u8; MAX_FRAME];
let n = frame::encode(unit, cmd, payload, &mut req).unwrap();
let snap = snapshot();
let n = frame::encode(UNIT, fc, data, &mut req).unwrap();
let mut out = [0u8; MAX_FRAME];
match dispatch(UNIT, &req[..n], &snap, &mut out) {
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)
(copy, len)
}
other => panic!("expected a reply, got {other:?}"),
}
}
fn exchange(fc: u8, data: &[u8]) -> ([u8; MAX_FRAME], usize) {
exchange_with(&snapshot(), fc, data)
}
/// A register read as a master issues it.
fn read(fc: u8, start: u16, count: u16) -> ([u8; MAX_FRAME], usize) {
let s = start.to_be_bytes();
let c = count.to_be_bytes();
exchange(fc, &[s[0], s[1], c[0], c[1]])
}
/// Decode a `0x03`/`0x04` response body into registers.
fn registers_of(buf: &[u8]) -> Vec<u16> {
let f = Frame::parse(buf).expect("response did not parse");
assert_eq!(f.fc & EXCEPTION_FLAG, 0, "unexpected exception response");
let byte_count = f.data[0] as usize;
assert_eq!(
byte_count,
f.data.len() - 1,
"byte count disagrees with the response length"
);
f.data[1..]
.chunks(2)
.map(|c| u16::from_be_bytes([c[0], c[1]]))
.collect()
}
/// The exception code of a response, or `None` if it was a success.
fn exception_of(buf: &[u8], expect_fc: u8) -> Option<u8> {
let f = Frame::parse(buf).expect("response did not parse");
if f.fc & EXCEPTION_FLAG == 0 {
return None;
}
assert_eq!(f.fc, expect_fc | EXCEPTION_FLAG);
assert_eq!(f.data.len(), 1, "an exception carries exactly one byte");
Some(f.data[0])
}
#[test]
fn read_measurement_roundtrips_through_the_wire() {
let (snap, buf, n) = exchange(UNIT, cmd::READ_MEASUREMENT, &[]);
fn measurement_roundtrips_through_the_wire() {
let snap = snapshot();
for fc in [fc::READ_HOLDING_REGISTERS, fc::READ_INPUT_REGISTERS] {
let (buf, n) = read(fc, reg::TEMP_MILLI_C, 5);
let regs = registers_of(&buf[..n]);
let image: [u16; 5] = regs.try_into().expect("expected 5 registers");
assert_eq!(
Measurement::from_registers(&image),
snap.measurement.unwrap(),
"function code {fc:#04x}"
);
}
}
/// Registers are big-endian and 32-bit values are high word first. This is
/// the single most likely thing to get wrong, and a master would silently
/// report a wildly wrong temperature rather than fail.
#[test]
fn register_byte_order_is_big_endian_high_word_first() {
let (buf, n) = read(fc::READ_INPUT_REGISTERS, reg::TEMP_MILLI_C, 2);
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());
// 23_450 = 0x00005B9A
assert_eq!(&f.data[1..5], &[0x00, 0x00, 0x5B, 0x9A]);
}
#[test]
fn negative_temperature_survives_the_encoding() {
let m = Measurement {
let mut snap = snapshot();
snap.measurement = Some(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);
});
let s = reg::TEMP_MILLI_C.to_be_bytes();
let (buf, n) = exchange_with(&snap, fc::READ_INPUT_REGISTERS, &[s[0], s[1], 0, 5]);
let image: [u16; 5] = registers_of(&buf[..n]).try_into().unwrap();
assert_eq!(Measurement::from_registers(&image).temp_milli_c, -12_345);
}
#[test]
fn read_info_payload_layout() {
let (snap, buf, n) = exchange(UNIT, cmd::READ_INFO, &[]);
fn info_and_status_registers_carry_the_snapshot() {
let snap = snapshot();
let (buf, n) = read(fc::READ_HOLDING_REGISTERS, 0, reg::COUNT);
let r = registers_of(&buf[..n]);
assert_eq!(r.len(), reg::COUNT as usize);
assert_eq!(r[reg::FLAGS as usize], snap.status.flags);
assert_eq!(r[reg::SENSOR_STATUS as usize], snap.status.sensor_status);
assert_eq!(r[reg::CRC_ERRORS as usize], snap.status.crc_errors);
assert_eq!(
r[reg::FW_PATCH_PROTO as usize] & 0xFF,
u16::from(PROTOCOL_VERSION)
);
assert_eq!(get_u32(&r, reg::DEVICE_SERIAL), snap.info.device_serial);
assert_eq!(get_u32(&r, reg::SENSOR_SERIAL), snap.info.sensor_serial);
assert_eq!(get_u32(&r, reg::UPTIME_S), snap.info.uptime_s);
}
/// A sub-range read must agree with the same registers read as part of a
/// larger block, which is what lets a master coalesce reads freely.
#[test]
fn subrange_reads_agree_with_the_whole_map() {
let (whole, wn) = read(fc::READ_INPUT_REGISTERS, 0, reg::COUNT);
let all = registers_of(&whole[..wn]);
for start in 0..reg::COUNT {
for count in 1..=(reg::COUNT - start) {
let (buf, n) = read(fc::READ_INPUT_REGISTERS, start, count);
let part = registers_of(&buf[..n]);
assert_eq!(
part,
all[start as usize..(start + count) as usize],
"start {start}, count {count}"
);
}
}
}
#[test]
fn read_past_the_end_is_an_illegal_address() {
for (start, count) in [(reg::COUNT, 1), (reg::COUNT - 1, 2), (0, reg::COUNT + 1)] {
let (buf, n) = read(fc::READ_INPUT_REGISTERS, start, count);
assert_eq!(
exception_of(&buf[..n], fc::READ_INPUT_REGISTERS),
Some(exception::ILLEGAL_DATA_ADDRESS),
"start {start}, count {count}"
);
}
}
/// A start address high enough that `start + count` would wrap in 16-bit
/// arithmetic must still be refused rather than looping back into the map.
#[test]
fn a_wrapping_range_is_refused() {
let (buf, n) = read(fc::READ_INPUT_REGISTERS, 0xFFFF, 2);
assert_eq!(
exception_of(&buf[..n], fc::READ_INPUT_REGISTERS),
Some(exception::ILLEGAL_DATA_ADDRESS)
);
}
#[test]
fn an_impossible_count_is_an_illegal_value() {
for count in [0, MAX_READ_REGISTERS + 1, 0xFFFF] {
let (buf, n) = read(fc::READ_INPUT_REGISTERS, 0, count);
assert_eq!(
exception_of(&buf[..n], fc::READ_INPUT_REGISTERS),
Some(exception::ILLEGAL_DATA_VALUE),
"count {count}"
);
}
}
#[test]
fn a_misshaped_request_is_an_illegal_value() {
for data in [&[][..], &[0x00][..], &[0, 0, 0][..], &[0, 0, 0, 1, 0][..]] {
let (buf, n) = exchange(fc::READ_INPUT_REGISTERS, data);
assert_eq!(
exception_of(&buf[..n], fc::READ_INPUT_REGISTERS),
Some(exception::ILLEGAL_DATA_VALUE),
"request data {data:02x?}"
);
}
}
#[test]
fn unknown_function_code_is_refused() {
for fc in [0x01, 0x02, 0x06, 0x10, 0x7E] {
let (buf, n) = exchange(fc, &[0, 0, 0, 1]);
assert_eq!(
exception_of(&buf[..n], fc),
Some(exception::ILLEGAL_FUNCTION),
"function code {fc:#04x}"
);
}
}
#[test]
fn diagnostic_echoes_the_data_field() {
let probe = [0x00, 0x00, 0xFF, 0x55, 0xAA, 0x0D, 0x0A];
let (buf, n) = exchange(fc::DIAGNOSTIC, &probe);
let f = Frame::parse(&buf[..n]).unwrap();
assert_eq!(f.payload.len(), Info::LEN);
assert_eq!(f.payload[3], PROTOCOL_VERSION);
assert_eq!(f.fc, fc::DIAGNOSTIC);
assert_eq!(f.data, &probe);
}
#[test]
fn diagnostic_echoes_a_maximum_length_field() {
let mut probe = [0x5Au8; frame::MAX_DATA];
probe[0] = 0;
probe[1] = 0;
let (buf, n) = exchange(fc::DIAGNOSTIC, &probe);
assert_eq!(Frame::parse(&buf[..n]).unwrap().data, &probe);
assert_eq!(n, MAX_FRAME);
}
#[test]
fn unknown_diagnostic_subfunction_is_refused() {
let (buf, n) = exchange(fc::DIAGNOSTIC, &[0x00, 0x0A]);
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
exception_of(&buf[..n], fc::DIAGNOSTIC),
Some(exception::ILLEGAL_FUNCTION)
);
}
#[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);
fn a_truncated_diagnostic_is_an_illegal_value() {
let (buf, n) = exchange(fc::DIAGNOSTIC, &[0x00]);
assert_eq!(
u16::from_le_bytes([f.payload[1], f.payload[2]]),
snap.status.sensor_status
exception_of(&buf[..n], fc::DIAGNOSTIC),
Some(exception::ILLEGAL_DATA_VALUE)
);
}
#[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]
@@ -414,7 +680,8 @@ mod tests {
frame::ADDR_MAX,
] {
let mut req = [0u8; MAX_FRAME];
let n = frame::encode(addr, cmd::READ_MEASUREMENT, &[], &mut req).unwrap();
let n =
frame::encode(addr, fc::READ_INPUT_REGISTERS, &[0, 0, 0, 5], &mut req).unwrap();
let mut out = [0u8; MAX_FRAME];
assert_eq!(
dispatch(UNIT, &req[..n], &snap, &mut out),
@@ -430,12 +697,12 @@ mod tests {
let mut out = [0u8; MAX_FRAME];
assert_eq!(
dispatch(UNIT, &[0x11, 0x01], &snap, &mut out),
dispatch(UNIT, &[0x11, 0x03], &snap, &mut out),
Outcome::Malformed(ParseError::TooShort)
);
let mut req = [0u8; MAX_FRAME];
let n = frame::encode(UNIT, cmd::READ_MEASUREMENT, &[], &mut req).unwrap();
let n = frame::encode(UNIT, fc::READ_INPUT_REGISTERS, &[0, 0, 0, 5], &mut req).unwrap();
req[n - 1] ^= 0xFF; // corrupt the CRC
assert_eq!(
dispatch(UNIT, &req[..n], &snap, &mut out),
@@ -444,80 +711,55 @@ mod tests {
}
#[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() {
fn missing_reading_fails_only_the_measurement_registers() {
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:?}"),
// Anything overlapping the measurement block is refused...
for (start, count) in [(reg::TEMP_MILLI_C, 1), (reg::AGE_MS, 2), (0, reg::COUNT)] {
let s = start.to_be_bytes();
let c = count.to_be_bytes();
let (buf, n) = exchange_with(&snap, fc::READ_INPUT_REGISTERS, &[s[0], s[1], c[0], c[1]]);
assert_eq!(
exception_of(&buf[..n], fc::READ_INPUT_REGISTERS),
Some(exception::SERVER_DEVICE_FAILURE),
"start {start}, count {count}"
);
}
// ...but the diagnostic and identity registers still read, which is how
// a master works out why the sensor is quiet.
let s = reg::FLAGS.to_be_bytes();
let count = reg::COUNT - reg::FLAGS;
let c = count.to_be_bytes();
let (buf, n) = exchange_with(&snap, fc::READ_INPUT_REGISTERS, &[s[0], s[1], c[0], c[1]]);
let r = registers_of(&buf[..n]);
assert_eq!(r.len(), count as usize);
assert_eq!(r[0], 0, "flags");
}
/// Every response we emit must itself be a frame our own parser accepts,
/// whatever function code and data field arrive.
#[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() {
fn all_responses_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);
for fc in 0x00u8..=0xFF {
for data in [&[][..], &[0, 0][..], &[0, 0, 0, 1][..], &[0xFF; 8][..]] {
let mut req = [0u8; MAX_FRAME];
let n = frame::encode(UNIT, fc, data, &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 response");
assert_eq!(f.addr, UNIT);
// Either the request's own function code, or that code with
// the exception flag set. Nothing else is a legal response.
assert!(
f.fc == fc || f.fc == fc | EXCEPTION_FLAG,
"function code {fc:#04x} answered as {:#04x}",
f.fc
);
}
}
}
}