diff --git a/docs/architecture.md b/docs/architecture.md index 9a48503..abe067d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -61,10 +61,18 @@ supervisor logic runs in CI with no panel. bench unit (though the *layout* fault — a load address in unreserved kernel RAM — is a target-config check, §5, not a sim property). - **`cru` — reset ladder.** Done. `CruSim` on `MemBus` (so `flared::devmem::hard_reset`'s - ladder is host-tested against the known glb_srst_fst / DW-watchdog registers); an - **NPU** load model behind the path seam; a **GPIO/relay** sysfs model; a **modbus - device** model unifying the existing `mbsim.py` corpus into the same framework; - an **RGA** recording fake. + ladder is host-tested against the known glb_srst_fst / DW-watchdog registers), plus + the boot-mode register's survives-warm-reset / cleared-by-POR behaviour (the MaskRom + recovery maneuver). flared's `devmem` now has a matching `Bus` seam and unit tests + that assert the shipped ladder pokes the confirmed offset, never the wrong-SoC one. +- **`modbus` — RS-485 device end.** Done. `ModbusSlave`: a byte-in/byte-out RTU slave + (CRC16 byte-identical to the master, FC 0x01–0x06/0x0F/0x10/0x11, exception replies, + and fault injection — silent-drop and forced-NAK) so `warden-modbus`'s master can be + hardened to MC/DC against realistic device behaviour with no serial hardware. MEI + (0x2B/0x0E) identification is the documented follow-up. +- **Next:** an **NPU** load model behind the path seam (deferred — no NPU feature + ships soon); a **GPIO/relay** sysfs model (largely covered by the `WARDEN_GPIO_ROOT` + seam in flare-edge's `tests/relays-mcdc/`); an **RGA** recording fake. Integration with flare-edge: flared implements `MemBus` for `/dev/mem` and gains `#[cfg(test)]` tests driving its real arm/beat logic against `HpmcuSim`. This needs diff --git a/sim/src/lib.rs b/sim/src/lib.rs index 9199176..43b60a4 100644 --- a/sim/src/lib.rs +++ b/sim/src/lib.rs @@ -2,17 +2,21 @@ //! //! Lets driver and supervisor logic run and be tested on the host, with no panel, //! by modelling the RV1106 hardware the vendor SDK cannot: the register/SRAM bus -//! ([`membus`]), the RISC-V HPMCU watchdog coprocessor ([`hpmcu`]), and — as they -//! land — the RGA blitter and the NPU. +//! ([`membus`]), the RISC-V HPMCU watchdog coprocessor ([`hpmcu`]), the CRU reset +//! ladder ([`cru`]), and the RS-485 device end ([`modbus`]) — and, as they land, +//! the RGA blitter and the NPU. //! //! Design: one [`membus::MemBus`] seam, two backends. On the host, [`membus::SimBus`] //! is an in-memory word map; on the device, flared's `devmem.rs` implements the same //! trait over `/dev/mem`, so the same code runs against either. See the repo README. +//! (The [`modbus`] slave rides a byte-stream seam, not the register bus.) pub mod cru; pub mod hpmcu; pub mod membus; +pub mod modbus; pub use cru::{BootMode, CruSim, ResetCause}; pub use hpmcu::HpmcuSim; pub use membus::{MemBus, SimBus}; +pub use modbus::ModbusSlave; diff --git a/sim/src/modbus.rs b/sim/src/modbus.rs new file mode 100644 index 0000000..7112c44 --- /dev/null +++ b/sim/src/modbus.rs @@ -0,0 +1,429 @@ +//! 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) { + 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, + input: Vec, + coils: Vec, + discrete: Vec, + slave_id: Vec, + /// 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, +} + +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) { + 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> { + 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, 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, 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, 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, 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, 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, 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, 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 { + 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 { + 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 { + 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]); + } +}