First recursive code-review-harness pass over the authored SDK code (sim/,
config-lint, flowgen, drivers/{relays,freshness}, build/, ci.yml). Four parallel
dimension reviewers; all findings at every severity corrected per workspace rule.
Correctness / reliability:
- freshness.c min_budget_ms: use a `seen` flag, not `best==0`, as the empty
sentinel — a zero-tolerance (max_stale_ms==0) binding was silently widened to a
looser neighbour's budget. Regression test added; still 66/66 MC/DC.
- config-lint parse_reserved_ranges: match `reg` as a whole property token (ident
boundary before, `=` after) so `reg-names` / a `region-*` label no longer
mis-parses into a bogus reserved range.
- config-lint loader check: fail closed — flag any loader with a LOAD_ADDR that is
not a known-safe boot component, instead of only known MCU names, so a future
coprocessor ("Rtos"/"Bl32") can't slip past the 0x40000-brick gate.
- build-kernel.sh: sha256 verification is now mandatory (refuse to build if the pin
is missing) and the mktemp scratch tree is removed on exit (trap), while a
caller-provided WORK is left intact for CI artifact upload.
Test quality:
- freshness: added the age==max_stale boundary case and a clock-wraparound
(now < last_ok) fail-safe-to-UNKNOWN test.
- relays: unsetenv(WARDEN_GPIO_ROOT) at main() so the NULL-env arm is hermetic.
Security / CI:
- ci.yml: top-level `permissions: contents: read` (badges overrides to write);
pin taiki-e/install-action to commit SHA (v2.86.7).
Maintainability / docs:
- drivers/enforce-mcdc.sh: one shared, name-derived gate replaces the two
copy-pasted per-driver scripts; Makefiles call ../../enforce-mcdc.sh.
- docs/architecture.md: §3/§4/§6/§7 rewritten to match reality — NPU/RGA models,
config-lint, and the relays+freshness MC/DC harnesses are done; kernel §6 now
reflects the 5.10->6.18.46 forward-port (ADR-0001), not the superseded plan44/6.6.
- README: status blurb + layout table corrected (kernel/, .github/; stale ci/ and
patches/ descriptions fixed). hpmcu "8 tests" -> 7 in docs.
- freshness.{c,h}: ADR reference points at flare-edge ADR-0004 (warden-sdk's
ADR-0004 is the CI runner — number collision).
- normalize rustfmt drift across sim/ + config-lint.
All green: sim 37 tests, config-lint 8 tests, both drivers 100% MC/DC (relays
40/40, freshness 66/66), clippy clean under -D warnings, gitleaks clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017wB8KB3MMQztRDXCMCkPrf
442 lines
15 KiB
Rust
442 lines
15 KiB
Rust
//! Modbus RTU **slave** simulator — the device end of the RS-485 seam.
|
||
//!
|
||
//! flare-edge's `warden-modbus` is the *master/scanner*: it probes RS-485 for
|
||
//! VFDs and PDUs, identifies them, and reads their register maps. To harden that
|
||
//! master to MC/DC we need something for it to talk to that behaves like a real
|
||
//! slave — correct CRC framing, the data-plane function codes, exception replies,
|
||
//! and the annoying real-world faults (a cheap sensor that ignores a function, a
|
||
//! device that NAKs an unsupported code). This is that slave, in host memory:
|
||
//! feed it a request frame, get the response frame (or `None` when a real slave
|
||
//! would stay silent). No serial port, no hardware, fully deterministic.
|
||
//!
|
||
//! Scope is the data plane — read/write of holding & input registers, coils, and
|
||
//! discrete inputs (FC 0x01–0x06, 0x0F, 0x10) plus Report Slave ID (0x11) — which
|
||
//! is what a VFD/PDU register poll actually exercises. Identification via MEI
|
||
//! (0x2B/0x0E) is a documented follow-up.
|
||
|
||
/// Modbus exception codes (returned as `fc | 0x80`, then the code).
|
||
pub mod exc {
|
||
pub const ILLEGAL_FUNCTION: u8 = 0x01;
|
||
pub const ILLEGAL_DATA_ADDRESS: u8 = 0x02;
|
||
pub const ILLEGAL_DATA_VALUE: u8 = 0x03;
|
||
}
|
||
|
||
/// Modbus RTU CRC16 (poly 0xA001, low byte first on the wire). Identical to the
|
||
/// master's `crc16` — the two must agree or nothing frames.
|
||
pub fn crc16(bytes: &[u8]) -> u16 {
|
||
let mut crc: u16 = 0xFFFF;
|
||
for &b in bytes {
|
||
crc ^= b as u16;
|
||
for _ in 0..8 {
|
||
crc = if crc & 1 != 0 {
|
||
(crc >> 1) ^ 0xA001
|
||
} else {
|
||
crc >> 1
|
||
};
|
||
}
|
||
}
|
||
crc
|
||
}
|
||
|
||
/// Append the RTU CRC (low byte then high) to a frame body in place.
|
||
pub fn append_crc(frame: &mut Vec<u8>) {
|
||
let c = crc16(frame);
|
||
frame.push((c & 0xFF) as u8);
|
||
frame.push((c >> 8) as u8);
|
||
}
|
||
|
||
/// True if `frame` (including its trailing 2 CRC bytes) has a valid CRC.
|
||
pub fn crc_ok(frame: &[u8]) -> bool {
|
||
if frame.len() < 3 {
|
||
return false;
|
||
}
|
||
let (body, crc) = frame.split_at(frame.len() - 2);
|
||
crc == [(crc16(body) & 0xFF) as u8, (crc16(body) >> 8) as u8]
|
||
}
|
||
|
||
/// A Modbus RTU slave device model.
|
||
pub struct ModbusSlave {
|
||
pub address: u8,
|
||
holding: Vec<u16>,
|
||
input: Vec<u16>,
|
||
coils: Vec<bool>,
|
||
discrete: Vec<bool>,
|
||
slave_id: Vec<u8>,
|
||
/// Silently drop this many upcoming requests (models a device that ignores a
|
||
/// function, or a flaky bus) — the master must time out and move on.
|
||
drop_next: usize,
|
||
/// Force every function to answer with this exception (models a device that
|
||
/// NAKs everything but a narrow set) until cleared.
|
||
force_exception: Option<u8>,
|
||
}
|
||
|
||
impl ModbusSlave {
|
||
/// A slave at `address` with `regs` holding & input registers and `bits`
|
||
/// coils & discrete inputs, all zero.
|
||
pub fn new(address: u8, regs: usize, bits: usize) -> Self {
|
||
Self {
|
||
address,
|
||
holding: vec![0; regs],
|
||
input: vec![0; regs],
|
||
coils: vec![false; bits],
|
||
discrete: vec![false; bits],
|
||
slave_id: vec![0xFF, 0xFF], // run-indicator on; arbitrary id byte
|
||
drop_next: 0,
|
||
force_exception: None,
|
||
}
|
||
}
|
||
|
||
pub fn set_holding(&mut self, addr: usize, v: u16) {
|
||
self.holding[addr] = v;
|
||
}
|
||
pub fn set_input(&mut self, addr: usize, v: u16) {
|
||
self.input[addr] = v;
|
||
}
|
||
pub fn set_coil(&mut self, addr: usize, v: bool) {
|
||
self.coils[addr] = v;
|
||
}
|
||
pub fn set_discrete(&mut self, addr: usize, v: bool) {
|
||
self.discrete[addr] = v;
|
||
}
|
||
pub fn holding(&self, addr: usize) -> u16 {
|
||
self.holding[addr]
|
||
}
|
||
pub fn coil(&self, addr: usize) -> bool {
|
||
self.coils[addr]
|
||
}
|
||
/// Set the Report-Slave-ID payload (everything after the byte-count).
|
||
pub fn set_slave_id(&mut self, id: Vec<u8>) {
|
||
self.slave_id = id;
|
||
}
|
||
|
||
/// Ignore the next `n` requests entirely (no response).
|
||
pub fn drop_next(&mut self, n: usize) {
|
||
self.drop_next = n;
|
||
}
|
||
/// Answer every request with `code` until [`clear_faults`](Self::clear_faults).
|
||
pub fn force_exception(&mut self, code: u8) {
|
||
self.force_exception = Some(code);
|
||
}
|
||
pub fn clear_faults(&mut self) {
|
||
self.drop_next = 0;
|
||
self.force_exception = None;
|
||
}
|
||
|
||
/// Handle one RTU request frame; return the RTU response frame, or `None`
|
||
/// when a real slave would stay silent (bad CRC, wrong address, broadcast,
|
||
/// or an injected drop).
|
||
pub fn handle_frame(&mut self, req: &[u8]) -> Option<Vec<u8>> {
|
||
if self.drop_next > 0 {
|
||
self.drop_next -= 1;
|
||
return None;
|
||
}
|
||
if req.len() < 4 || !crc_ok(req) {
|
||
return None; // RTU slaves silently discard malformed / bad-CRC frames
|
||
}
|
||
let addr = req[0];
|
||
if addr != self.address {
|
||
return None; // not for us (address 0 = broadcast: no reply either)
|
||
}
|
||
let fc = req[1];
|
||
let pdu = &req[2..req.len() - 2]; // between address and CRC
|
||
|
||
let body = if let Some(code) = self.force_exception {
|
||
Err(code)
|
||
} else {
|
||
self.dispatch(fc, pdu)
|
||
};
|
||
|
||
let mut frame = vec![self.address];
|
||
match body {
|
||
Ok(mut data) => {
|
||
frame.push(fc);
|
||
frame.append(&mut data);
|
||
}
|
||
Err(code) => {
|
||
frame.push(fc | 0x80);
|
||
frame.push(code);
|
||
}
|
||
}
|
||
append_crc(&mut frame);
|
||
Some(frame)
|
||
}
|
||
|
||
/// Build the response PDU (everything between fc and CRC) or an exception.
|
||
fn dispatch(&mut self, fc: u8, pdu: &[u8]) -> Result<Vec<u8>, u8> {
|
||
match fc {
|
||
0x01 => self.read_bits(pdu, false),
|
||
0x02 => self.read_bits(pdu, true),
|
||
0x03 => self.read_regs(pdu, false),
|
||
0x04 => self.read_regs(pdu, true),
|
||
0x05 => self.write_single_coil(pdu),
|
||
0x06 => self.write_single_reg(pdu),
|
||
0x0F => self.write_multi_coils(pdu),
|
||
0x10 => self.write_multi_regs(pdu),
|
||
0x11 => Ok(self.report_slave_id()),
|
||
_ => Err(exc::ILLEGAL_FUNCTION),
|
||
}
|
||
}
|
||
|
||
fn read_regs(&self, pdu: &[u8], input: bool) -> Result<Vec<u8>, u8> {
|
||
if pdu.len() != 4 {
|
||
return Err(exc::ILLEGAL_DATA_VALUE);
|
||
}
|
||
let start = u16::from_be_bytes([pdu[0], pdu[1]]) as usize;
|
||
let count = u16::from_be_bytes([pdu[2], pdu[3]]) as usize;
|
||
if count == 0 || count > 125 {
|
||
return Err(exc::ILLEGAL_DATA_VALUE);
|
||
}
|
||
let bank = if input { &self.input } else { &self.holding };
|
||
if start + count > bank.len() {
|
||
return Err(exc::ILLEGAL_DATA_ADDRESS);
|
||
}
|
||
let mut out = vec![(count * 2) as u8];
|
||
for r in &bank[start..start + count] {
|
||
out.extend_from_slice(&r.to_be_bytes());
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
fn read_bits(&self, pdu: &[u8], discrete: bool) -> Result<Vec<u8>, u8> {
|
||
if pdu.len() != 4 {
|
||
return Err(exc::ILLEGAL_DATA_VALUE);
|
||
}
|
||
let start = u16::from_be_bytes([pdu[0], pdu[1]]) as usize;
|
||
let count = u16::from_be_bytes([pdu[2], pdu[3]]) as usize;
|
||
if count == 0 || count > 2000 {
|
||
return Err(exc::ILLEGAL_DATA_VALUE);
|
||
}
|
||
let bank = if discrete {
|
||
&self.discrete
|
||
} else {
|
||
&self.coils
|
||
};
|
||
if start + count > bank.len() {
|
||
return Err(exc::ILLEGAL_DATA_ADDRESS);
|
||
}
|
||
let nbytes = count.div_ceil(8);
|
||
let mut out = vec![nbytes as u8];
|
||
out.extend(std::iter::repeat_n(0u8, nbytes));
|
||
for (i, &bit) in bank[start..start + count].iter().enumerate() {
|
||
if bit {
|
||
out[1 + i / 8] |= 1 << (i % 8);
|
||
}
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
fn write_single_reg(&mut self, pdu: &[u8]) -> Result<Vec<u8>, u8> {
|
||
if pdu.len() != 4 {
|
||
return Err(exc::ILLEGAL_DATA_VALUE);
|
||
}
|
||
let addr = u16::from_be_bytes([pdu[0], pdu[1]]) as usize;
|
||
let val = u16::from_be_bytes([pdu[2], pdu[3]]);
|
||
if addr >= self.holding.len() {
|
||
return Err(exc::ILLEGAL_DATA_ADDRESS);
|
||
}
|
||
self.holding[addr] = val;
|
||
Ok(pdu.to_vec()) // echo request PDU
|
||
}
|
||
|
||
fn write_single_coil(&mut self, pdu: &[u8]) -> Result<Vec<u8>, u8> {
|
||
if pdu.len() != 4 {
|
||
return Err(exc::ILLEGAL_DATA_VALUE);
|
||
}
|
||
let addr = u16::from_be_bytes([pdu[0], pdu[1]]) as usize;
|
||
let val = u16::from_be_bytes([pdu[2], pdu[3]]);
|
||
if val != 0x0000 && val != 0xFF00 {
|
||
return Err(exc::ILLEGAL_DATA_VALUE);
|
||
}
|
||
if addr >= self.coils.len() {
|
||
return Err(exc::ILLEGAL_DATA_ADDRESS);
|
||
}
|
||
self.coils[addr] = val == 0xFF00;
|
||
Ok(pdu.to_vec())
|
||
}
|
||
|
||
fn write_multi_regs(&mut self, pdu: &[u8]) -> Result<Vec<u8>, u8> {
|
||
if pdu.len() < 5 {
|
||
return Err(exc::ILLEGAL_DATA_VALUE);
|
||
}
|
||
let start = u16::from_be_bytes([pdu[0], pdu[1]]) as usize;
|
||
let count = u16::from_be_bytes([pdu[2], pdu[3]]) as usize;
|
||
let bytecount = pdu[4] as usize;
|
||
if count == 0 || count > 123 || bytecount != count * 2 || pdu.len() != 5 + bytecount {
|
||
return Err(exc::ILLEGAL_DATA_VALUE);
|
||
}
|
||
if start + count > self.holding.len() {
|
||
return Err(exc::ILLEGAL_DATA_ADDRESS);
|
||
}
|
||
for i in 0..count {
|
||
self.holding[start + i] = u16::from_be_bytes([pdu[5 + i * 2], pdu[6 + i * 2]]);
|
||
}
|
||
Ok(pdu[0..4].to_vec()) // echo address + count
|
||
}
|
||
|
||
fn write_multi_coils(&mut self, pdu: &[u8]) -> Result<Vec<u8>, u8> {
|
||
if pdu.len() < 5 {
|
||
return Err(exc::ILLEGAL_DATA_VALUE);
|
||
}
|
||
let start = u16::from_be_bytes([pdu[0], pdu[1]]) as usize;
|
||
let count = u16::from_be_bytes([pdu[2], pdu[3]]) as usize;
|
||
let bytecount = pdu[4] as usize;
|
||
if count == 0
|
||
|| count > 1968
|
||
|| bytecount != count.div_ceil(8)
|
||
|| pdu.len() != 5 + bytecount
|
||
{
|
||
return Err(exc::ILLEGAL_DATA_VALUE);
|
||
}
|
||
if start + count > self.coils.len() {
|
||
return Err(exc::ILLEGAL_DATA_ADDRESS);
|
||
}
|
||
for i in 0..count {
|
||
self.coils[start + i] = pdu[5 + i / 8] & (1 << (i % 8)) != 0;
|
||
}
|
||
Ok(pdu[0..4].to_vec())
|
||
}
|
||
|
||
fn report_slave_id(&self) -> Vec<u8> {
|
||
let mut out = vec![self.slave_id.len() as u8];
|
||
out.extend_from_slice(&self.slave_id);
|
||
out
|
||
}
|
||
}
|
||
|
||
/// Build an RTU request frame (with CRC) — convenience for tests and for driving
|
||
/// the master's parser. `pdu` is everything between the address and the CRC
|
||
/// (i.e. `fc` followed by its data).
|
||
pub fn request(address: u8, pdu: &[u8]) -> Vec<u8> {
|
||
let mut f = vec![address];
|
||
f.extend_from_slice(pdu);
|
||
append_crc(&mut f);
|
||
f
|
||
}
|
||
|
||
/// Build a read-holding-registers (FC 0x03) request frame.
|
||
pub fn read_holding(address: u8, start: u16, count: u16) -> Vec<u8> {
|
||
let mut pdu = vec![0x03];
|
||
pdu.extend_from_slice(&start.to_be_bytes());
|
||
pdu.extend_from_slice(&count.to_be_bytes());
|
||
request(address, &pdu)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// Known Modbus RTU CRC vector: `01 03 00 00 00 01` -> low `84`, high `0A`.
|
||
#[test]
|
||
fn crc_known_vector() {
|
||
assert_eq!(crc16(&[0x01, 0x03, 0x00, 0x00, 0x00, 0x01]), 0x0A84);
|
||
let f = read_holding(1, 0, 1);
|
||
assert_eq!(&f[f.len() - 2..], &[0x84, 0x0A]);
|
||
assert!(crc_ok(&f));
|
||
}
|
||
|
||
#[test]
|
||
fn read_holding_returns_values() {
|
||
let mut s = ModbusSlave::new(0x11, 16, 0);
|
||
s.set_holding(2, 0xABCD);
|
||
s.set_holding(3, 0x1234);
|
||
let resp = s.handle_frame(&read_holding(0x11, 2, 2)).unwrap();
|
||
// addr fc bytecount d d d d crc crc
|
||
assert_eq!(resp[0], 0x11);
|
||
assert_eq!(resp[1], 0x03);
|
||
assert_eq!(resp[2], 4); // 2 regs * 2 bytes
|
||
assert_eq!(&resp[3..7], &[0xAB, 0xCD, 0x12, 0x34]);
|
||
assert!(crc_ok(&resp));
|
||
}
|
||
|
||
#[test]
|
||
fn write_single_register_updates_and_echoes() {
|
||
let mut s = ModbusSlave::new(1, 8, 0);
|
||
let req = request(1, &[0x06, 0x00, 0x05, 0x07, 0xD0]); // write 2000 -> reg 5
|
||
let resp = s.handle_frame(&req).unwrap();
|
||
assert_eq!(s.holding(5), 2000);
|
||
assert_eq!(&resp[1..6], &[0x06, 0x00, 0x05, 0x07, 0xD0]); // echo
|
||
}
|
||
|
||
#[test]
|
||
fn write_multiple_registers() {
|
||
let mut s = ModbusSlave::new(1, 8, 0);
|
||
// FC10 start=0 count=2 bytecount=4 vals=0x1111,0x2222
|
||
let req = request(1, &[0x10, 0, 0, 0, 2, 4, 0x11, 0x11, 0x22, 0x22]);
|
||
let resp = s.handle_frame(&req).unwrap();
|
||
assert_eq!(s.holding(0), 0x1111);
|
||
assert_eq!(s.holding(1), 0x2222);
|
||
assert_eq!(&resp[1..6], &[0x10, 0, 0, 0, 2]); // echo addr+count
|
||
}
|
||
|
||
#[test]
|
||
fn coils_write_then_read() {
|
||
let mut s = ModbusSlave::new(1, 0, 16);
|
||
// FC05 write single coil 3 = ON
|
||
s.handle_frame(&request(1, &[0x05, 0, 3, 0xFF, 0x00]))
|
||
.unwrap();
|
||
assert!(s.coil(3));
|
||
// FC01 read coils 0..8 -> bit 3 set => byte 0x08
|
||
let resp = s.handle_frame(&request(1, &[0x01, 0, 0, 0, 8])).unwrap();
|
||
assert_eq!(resp[2], 1); // 1 byte
|
||
assert_eq!(resp[3], 0x08);
|
||
}
|
||
|
||
#[test]
|
||
fn illegal_function_returns_exception() {
|
||
let mut s = ModbusSlave::new(1, 8, 0);
|
||
let resp = s.handle_frame(&request(1, &[0x63, 0, 0])).unwrap();
|
||
assert_eq!(resp[1], 0x63 | 0x80);
|
||
assert_eq!(resp[2], exc::ILLEGAL_FUNCTION);
|
||
assert!(crc_ok(&resp));
|
||
}
|
||
|
||
#[test]
|
||
fn out_of_range_read_is_illegal_data_address() {
|
||
let mut s = ModbusSlave::new(1, 4, 0);
|
||
let resp = s.handle_frame(&read_holding(1, 2, 10)).unwrap();
|
||
assert_eq!(resp[1], 0x83);
|
||
assert_eq!(resp[2], exc::ILLEGAL_DATA_ADDRESS);
|
||
}
|
||
|
||
#[test]
|
||
fn wrong_address_and_bad_crc_are_silent() {
|
||
let mut s = ModbusSlave::new(0x11, 8, 0);
|
||
assert!(s.handle_frame(&read_holding(0x12, 0, 1)).is_none()); // other slave
|
||
let mut bad = read_holding(0x11, 0, 1);
|
||
*bad.last_mut().unwrap() ^= 0xFF; // corrupt CRC
|
||
assert!(s.handle_frame(&bad).is_none());
|
||
}
|
||
|
||
/// A device that ignores the next request (the "cheap sensor" the master
|
||
/// comment warns about) — the master must fall through to the next function.
|
||
#[test]
|
||
fn drop_next_models_a_silent_device() {
|
||
let mut s = ModbusSlave::new(1, 8, 0);
|
||
s.drop_next(1);
|
||
assert!(s.handle_frame(&read_holding(1, 0, 1)).is_none()); // ignored
|
||
assert!(s.handle_frame(&read_holding(1, 0, 1)).is_some()); // recovers
|
||
}
|
||
|
||
/// A device that NAKs everything (forced exception) until cleared.
|
||
#[test]
|
||
fn force_exception_naks_until_cleared() {
|
||
let mut s = ModbusSlave::new(1, 8, 0);
|
||
s.force_exception(exc::ILLEGAL_FUNCTION);
|
||
let resp = s.handle_frame(&read_holding(1, 0, 1)).unwrap();
|
||
assert_eq!(resp[1], 0x83);
|
||
s.clear_faults();
|
||
let resp = s.handle_frame(&read_holding(1, 0, 1)).unwrap();
|
||
assert_eq!(resp[1], 0x03); // normal again
|
||
}
|
||
|
||
#[test]
|
||
fn report_slave_id() {
|
||
let mut s = ModbusSlave::new(7, 4, 0);
|
||
s.set_slave_id(vec![0x42, 0xFF]);
|
||
let resp = s.handle_frame(&request(7, &[0x11])).unwrap();
|
||
assert_eq!(resp[1], 0x11);
|
||
assert_eq!(resp[2], 2); // byte count
|
||
assert_eq!(&resp[3..5], &[0x42, 0xFF]);
|
||
}
|
||
}
|