From daa880689a2a71e7e06fcb9436059dda6d591fbe Mon Sep 17 00:00:00 2001 From: Claas Date: Sun, 30 Aug 2026 22:46:00 +0200 Subject: [PATCH] Add the modbus coil functions the bypass relay speaks The relay that opens the bypass is a coil device, not a register device: it takes its position through write single coil (0x05) and reports it through read coils (0x01), neither of which the client could speak. `WriteSingleCoil` is the same eight byte frame as `WriteHoldingRegister` and comes back as the same echo, so `transact_write` is now shared between them. The one thing that differed was how much of the echoed value has to match -- the fan quietly drops the four low bits of a set point, a coil has nothing to drop -- so that moved out of the function and into an argument. `ReadCoil` asks for exactly one coil for the same reason `ReadHoldingRegister` asks for exactly one register: it keeps the answer a fixed length. Coil addresses get their own newtype rather than reusing the register one. On this relay the two spaces genuinely collide: coil 0x0000 is the relay, while holding register 0x0000 is the device address of the whole module, and mixing them up would reassign the module instead of switching the bypass. The client is no longer talking only to fans, so `fan_identifier` is now `device_identifier` and the log lines and doc comments in the shared paths say device. The exception codes are the standard modbus ones and apply unchanged; their comments now say so, since only the fan documents them. All three frames the relay manual gives as examples reproduce byte for byte, CRC and its byte order included. Nothing calls any of this yet -- the routine that drives the bypass is the next layer -- so the new items carry `#[allow(dead_code)]` until it does. Co-Authored-By: Claude Opus 5 --- fan-controller/src/modbus/client.rs | 250 +++++++++++++----- fan-controller/src/modbus/coil.rs | 41 +++ fan-controller/src/modbus/function/code.rs | 12 + fan-controller/src/modbus/function/mod.rs | 4 + .../src/modbus/function/read_coil.rs | 52 ++++ .../src/modbus/function/write_single_coil.rs | 56 ++++ fan-controller/src/modbus/mod.rs | 1 + 7 files changed, 352 insertions(+), 64 deletions(-) create mode 100644 fan-controller/src/modbus/coil.rs create mode 100644 fan-controller/src/modbus/function/read_coil.rs create mode 100644 fan-controller/src/modbus/function/write_single_coil.rs diff --git a/fan-controller/src/modbus/client.rs b/fan-controller/src/modbus/client.rs index dc70dcc..70db58a 100644 --- a/fan-controller/src/modbus/client.rs +++ b/fan-controller/src/modbus/client.rs @@ -11,11 +11,12 @@ use embedded_io_async::{Read, ReadExactError, Write}; use crate::{ configuration, modbus::function::{ - ReadHoldingRegister, ReadInputRegisters, WriteHoldingRegister, code, read_input_register, + ReadCoil, ReadHoldingRegister, ReadInputRegisters, WriteHoldingRegister, WriteSingleCoil, + code, read_input_register, }, }; -/// How sending a request to the fan failed +/// How sending a request to a device failed #[derive(Debug, Clone, Copy, defmt::Format)] pub(crate) enum SendFailure { /// Writing the request timed out @@ -28,7 +29,7 @@ pub(crate) enum SendFailure { /// caller, because it is the one that knows which read this was #[derive(Debug, Clone, Copy, defmt::Format)] pub(crate) enum ReceiveFailure { - /// Waiting for the bytes timed out. This is what a silent fan looks like + /// Waiting for the bytes timed out. This is what a silent device looks like Timeout, /// The UART failed while reading Uart, @@ -38,7 +39,7 @@ pub(crate) enum ReceiveFailure { /// The parts of a response that every function reads the same way. The header comes first because /// it decides how long the rest of the frame is, and an exception frame is what arrives in place -/// of an answer when the fan refuses +/// of an answer when the device refuses #[derive(Debug, Clone, Copy, defmt::Format)] pub(crate) enum Part { /// The device address and the function code @@ -47,11 +48,14 @@ pub(crate) enum Part { Exception, } -/// The exception codes the fan documents for write single register. +/// The exception codes a device can refuse a write with, whether it was to a register or to a +/// coil. They are the standard modbus ones, described the way the fan specification describes +/// them — the relay documents no exception codes at all. /// See MODBUS Parameter RadiCal im Spiralgehäuse V1.00, section 1.3.3 #[derive(Debug, Clone, Copy, defmt::Format)] pub(crate) enum WriteException { - /// The register address is outside the D000 ... D614 range the fan accepts + /// The address is one the device does not have. On the fan that is anything outside the + /// D000 ... D614 range it accepts RegisterOutOfRange, /// The register could not be written, because the electronics are defective or because this /// password level has no write permission for it @@ -71,11 +75,14 @@ impl From for WriteException { } } -/// The exception codes the fan documents for read holding register. +/// The exception codes a device can refuse a read with, whether it was of registers or of coils. +/// They are the standard modbus ones, described the way the fan specification describes them — +/// the relay documents no exception codes at all. /// See MODBUS Parameter RadiCal im Spiralgehäuse V1.00, section 1.3.1 #[derive(Debug, Clone, Copy, defmt::Format)] pub(crate) enum ReadException { - /// The register address is outside the D000 ... D614 range the fan accepts + /// The address is one the device does not have. On the fan that is anything outside the + /// D000 ... D614 range it accepts RegisterOutOfRange, /// The answer would exceed the 80 byte maximum telegram length, which means more than 37 or /// zero registers were asked for @@ -97,7 +104,7 @@ impl From for ReadException { } } -/// What the fan is answering with, which its header is what decides +/// What the device is answering with, which its header is what decides enum Answer { /// The answer to the function that was requested. Its body follows the header Requested, @@ -179,9 +186,22 @@ const HEADER_LENGTH: usize = 2; /// zero. See MODBUS Parameter RadiCal im Spiralgehäuse V1.00, section 2.3 const IGNORED_SET_POINT_BITS: u16 = 0x000F; -/// A successful response to a write holding register request echoes the request back +/// A successful response to a write holding register or write single coil request echoes the +/// request back const WRITE_RESPONSE_LENGTH: usize = 8; +/// A successful response to a read coil request: the header, the byte count, the one byte the +/// single coil that was asked for is packed into, and the checksum +// Allowed until the routine that drives the bypass is the caller +#[allow(dead_code)] +const READ_COIL_RESPONSE_LENGTH: usize = 6; + +/// How many data bytes a read of the single coil asked for has to announce. Modbus packs eight +/// coils into a byte, so asking for one still comes back as a whole byte +// Allowed until the routine that drives the bypass is the caller +#[allow(dead_code)] +const READ_COIL_BYTE_COUNT: u8 = 1; + /// A successful response to a read holding register request: the header, the byte count, the /// contents of the one register that was asked for, and the checksum const READ_RESPONSE_LENGTH: usize = 7; @@ -206,12 +226,14 @@ const EXCEPTION_RESPONSE_LENGTH: usize = 5; /// Modbus separates frames by 3.5 characters of silence which is about 2 ms at 19200 baud 8E1 const DISCARD_TIMEOUT: Duration = Duration::from_millis(5); -/// Which of the two fans a device address belongs to, for the log -fn fan_identifier(device_address: u8) -> &'static str { +/// Which device on the bus an address belongs to, for the log. The fans are not the only thing on +/// it any more, so an address that is not one of them is a device this function has not been told +/// about rather than a mistake +fn device_identifier(device_address: u8) -> &'static str { match device_address { 2 => "[Fan 1]", 3 => "[Fan 2]", - _other => "Unknown (oops)", + _other => "[Unknown device]", } } @@ -253,10 +275,47 @@ impl<'a, UART: uart::Instance, PIN: Pin> Client<'a, UART, PIN> { &mut self, message: &WriteHoldingRegister, ) -> Result<(), WriteError> { - let fan_identifier = fan_identifier(*message.device_address()); + let device_identifier = device_identifier(*message.device_address()); + + let result = self + .transact_write(message.as_ref(), IGNORED_SET_POINT_BITS, device_identifier) + .await; + self.clear_line_after(&result, device_identifier).await; + + result + } + + /// Drives a single coil and waits for the device to acknowledge it, which is how the bypass + /// relay is opened and closed + // Allowed until the routine that drives the bypass is the caller + #[allow(dead_code)] + pub(crate) async fn write_single_coil( + &mut self, + message: &WriteSingleCoil, + ) -> Result<(), WriteError> { + let device_identifier = device_identifier(*message.device_address()); - let result = self.transact_write(message, fan_identifier).await; - self.clear_line_after(&result, fan_identifier).await; + // Every bit of the echo has to match here, unlike a set point: a coil has two positions + // and nothing about the value that carries them is ignored + const IGNORED_VALUE_BITS: u16 = 0x0000; + let result = self + .transact_write(message.as_ref(), IGNORED_VALUE_BITS, device_identifier) + .await; + self.clear_line_after(&result, device_identifier).await; + + result + } + + /// Reads back which position a coil is in, which is how the bypass relay is asked where it + /// stands. It keeps its position while the controller resets, so this is worth asking on boot + /// rather than assuming + // Allowed until the routine that drives the bypass is the caller + #[allow(dead_code)] + pub(crate) async fn read_coil(&mut self, message: &ReadCoil) -> Result { + let device_identifier = device_identifier(*message.device_address()); + + let result = self.transact_read_coil(message, device_identifier).await; + self.clear_line_after(&result, device_identifier).await; result } @@ -267,12 +326,12 @@ impl<'a, UART: uart::Instance, PIN: Pin> Client<'a, UART, PIN> { &mut self, message: &ReadInputRegisters, ) -> Result<[u16; COUNT], ReadError> { - let fan_identifier = fan_identifier(*message.device_address()); + let device_identifier = device_identifier(*message.device_address()); let result = self - .transact_read_input_registers(message, fan_identifier) + .transact_read_input_registers(message, device_identifier) .await; - self.clear_line_after(&result, fan_identifier).await; + self.clear_line_after(&result, device_identifier).await; result } @@ -282,10 +341,10 @@ impl<'a, UART: uart::Instance, PIN: Pin> Client<'a, UART, PIN> { &mut self, message: &ReadHoldingRegister, ) -> Result { - let fan_identifier = fan_identifier(*message.device_address()); + let device_identifier = device_identifier(*message.device_address()); - let result = self.transact_read(message, fan_identifier).await; - self.clear_line_after(&result, fan_identifier).await; + let result = self.transact_read(message, device_identifier).await; + self.clear_line_after(&result, device_identifier).await; result } @@ -293,26 +352,30 @@ impl<'a, UART: uart::Instance, PIN: Pin> Client<'a, UART, PIN> { /// A failed transaction can leave part of a frame in the receive buffer. Dropping it keeps the /// next transaction from reading those leftovers as its own response. Both fans share this /// UART, so leftovers from one would otherwise be read as an answer from the other. - async fn clear_line_after(&mut self, result: &Result, fan_identifier: &str) { + async fn clear_line_after(&mut self, result: &Result, device_identifier: &str) { if result.is_err() { - self.discard_incoming(fan_identifier).await; + self.discard_incoming(device_identifier).await; } } + /// Writing a register and driving a coil are the same exchange: an eight byte request, and an + /// echo of it back. They differ only in how much of the echoed value has to match, which is + /// what `ignored_value_bits` names — a fan quietly drops the low bits of a set point, a coil + /// has nothing to drop async fn transact_write( &mut self, - message: &WriteHoldingRegister, - fan_identifier: &str, + request: &[u8], + ignored_value_bits: u16, + device_identifier: &str, ) -> Result<(), WriteError> { - let request = message.as_ref(); - self.send_request(request, fan_identifier).await?; + self.send_request(request, device_identifier).await?; // The response is either an echo of the request or a shorter exception frame, so the // address and function code are read first to find out which one is arriving. Reading // exactly as many bytes as the frame holds leaves nothing behind for the next transaction. let mut response = [0u8; WRITE_RESPONSE_LENGTH]; if let Answer::Exception(code) = self - .read_header(&mut response, request, fan_identifier) + .read_header(&mut response, request, device_identifier) .await? { return Err(WriteError::Exception(code.into())); @@ -325,53 +388,109 @@ impl<'a, UART: uart::Instance, PIN: Pin> Client<'a, UART, PIN> { if !is_checksum_valid(&response) { warn!( "{} Response failed checksum: {:?}", - fan_identifier, response + device_identifier, response ); return Err(WriteError::EchoChecksum); } - // The echo repeats the register and the value that were written. The register has to match + // The echo repeats the address and the value that were written. The address has to match // exactly, but the fan ignores the four least significant bits of a set point, and the // specification does not say whether it echoes back the bits it received or the value it // stored. Masking those bits on both sides accepts either without accepting a real // mismatch, and the checksum above still catches a corrupted frame - let echoed_register = u16::from_be_bytes([response[2], response[3]]); - let requested_register = u16::from_be_bytes([request[2], request[3]]); + let echoed_address = u16::from_be_bytes([response[2], response[3]]); + let requested_address = u16::from_be_bytes([request[2], request[3]]); let echoed_value = u16::from_be_bytes([response[4], response[5]]); let requested_value = u16::from_be_bytes([request[4], request[5]]); - if echoed_register != requested_register - || echoed_value & !IGNORED_SET_POINT_BITS != requested_value & !IGNORED_SET_POINT_BITS + if echoed_address != requested_address + || echoed_value & !ignored_value_bits != requested_value & !ignored_value_bits { warn!( "{} Response {:?} does not echo the request {:?}", - fan_identifier, response, request + device_identifier, response, request ); return Err(WriteError::EchoMismatch(response)); } info!( - "{} Fan acknowledged the write: {:?}", - fan_identifier, response + "{} Device acknowledged the write: {:?}", + device_identifier, response ); Ok(()) } + /// Unlike the register reads, one coil comes back as one bit in one byte rather than as a + /// value, so there is nothing to assemble out of the data bytes — only the lowest bit of the + /// only byte to look at + // Allowed until the routine that drives the bypass is the caller + #[allow(dead_code)] + async fn transact_read_coil( + &mut self, + message: &ReadCoil, + device_identifier: &str, + ) -> Result { + let request = message.as_ref(); + self.send_request(request, device_identifier).await?; + + let mut response = [0u8; READ_COIL_RESPONSE_LENGTH]; + if let Answer::Exception(code) = self + .read_header(&mut response, request, device_identifier) + .await? + { + return Err(ReadError::Exception(code.into())); + } + + self.receive_exact(&mut response[HEADER_LENGTH..]) + .await + .map_err(ReadError::Contents)?; + + // Checked before the checksum for the same reason as in the register reads: a different + // byte count means a frame of a different length was just read, so a failing checksum + // would report something other than what actually went wrong + if response[2] != READ_COIL_BYTE_COUNT { + warn!( + "{} Response announced {:?} data bytes instead of {:?}: {:?}", + device_identifier, response[2], READ_COIL_BYTE_COUNT, response + ); + return Err(ReadError::ByteCount(response[2])); + } + + if !is_checksum_valid(&response) { + warn!( + "{} Response failed checksum: {:?}", + device_identifier, response + ); + return Err(ReadError::ContentsChecksum); + } + + // Coils are packed into the byte from the lowest bit up and exactly one was asked for, so + // it is the only bit that carries anything. The rest are padding and are ignored rather + // than checked, because the specification does not say what a device puts there + let is_on = response[3] & 0b0000_0001 != 0; + info!( + "{} Device answered the read with {:?}: {:?}", + device_identifier, is_on, response + ); + + Ok(is_on) + } + async fn transact_read( &mut self, message: &ReadHoldingRegister, - fan_identifier: &str, + device_identifier: &str, ) -> Result { let request = message.as_ref(); - self.send_request(request, fan_identifier).await?; + self.send_request(request, device_identifier).await?; // Unlike the write, the answer does not repeat the request: it carries a byte count and // the register contents. Only one register was asked for, so its length is known in // advance and the byte count is a check rather than something to act on. let mut response = [0u8; READ_RESPONSE_LENGTH]; if let Answer::Exception(code) = self - .read_header(&mut response, request, fan_identifier) + .read_header(&mut response, request, device_identifier) .await? { return Err(ReadError::Exception(code.into())); @@ -387,7 +506,7 @@ impl<'a, UART: uart::Instance, PIN: Pin> Client<'a, UART, PIN> { if response[2] != READ_BYTE_COUNT { warn!( "{} Response announced {:?} data bytes instead of {:?}: {:?}", - fan_identifier, response[2], READ_BYTE_COUNT, response + device_identifier, response[2], READ_BYTE_COUNT, response ); return Err(ReadError::ByteCount(response[2])); } @@ -395,7 +514,7 @@ impl<'a, UART: uart::Instance, PIN: Pin> Client<'a, UART, PIN> { if !is_checksum_valid(&response) { warn!( "{} Response failed checksum: {:?}", - fan_identifier, response + device_identifier, response ); return Err(ReadError::ContentsChecksum); } @@ -403,7 +522,7 @@ impl<'a, UART: uart::Instance, PIN: Pin> Client<'a, UART, PIN> { let value = u16::from_be_bytes([response[3], response[4]]); info!( "{} Fan answered the read with {:?}: {:?}", - fan_identifier, value, response + device_identifier, value, response ); Ok(value) @@ -412,10 +531,10 @@ impl<'a, UART: uart::Instance, PIN: Pin> Client<'a, UART, PIN> { async fn transact_read_input_registers( &mut self, message: &ReadInputRegisters, - fan_identifier: &str, + device_identifier: &str, ) -> Result<[u16; COUNT], ReadError> { let request = message.as_ref(); - self.send_request(request, fan_identifier).await?; + self.send_request(request, device_identifier).await?; // Like the holding register read the answer carries a byte count and the contents rather // than repeating the request, so its length is known from the count that was asked for. @@ -424,7 +543,7 @@ impl<'a, UART: uart::Instance, PIN: Pin> Client<'a, UART, PIN> { let mut buffer = [0u8; MAX_INPUT_REGISTERS_RESPONSE_LENGTH]; let response = &mut buffer[..READ_OVERHEAD_LENGTH + 2 * COUNT]; - if let Answer::Exception(code) = self.read_header(response, request, fan_identifier).await? + if let Answer::Exception(code) = self.read_header(response, request, device_identifier).await? { return Err(ReadError::Exception(code.into())); } @@ -441,7 +560,7 @@ impl<'a, UART: uart::Instance, PIN: Pin> Client<'a, UART, PIN> { if response[2] != expected_byte_count { warn!( "{} Response announced {:?} data bytes instead of {:?}: {:?}", - fan_identifier, response[2], expected_byte_count, response + device_identifier, response[2], expected_byte_count, response ); return Err(ReadError::ByteCount(response[2])); } @@ -449,7 +568,7 @@ impl<'a, UART: uart::Instance, PIN: Pin> Client<'a, UART, PIN> { if !is_checksum_valid(response) { warn!( "{} Response failed checksum: {:?}", - fan_identifier, response + device_identifier, response ); return Err(ReadError::ContentsChecksum); } @@ -463,7 +582,7 @@ impl<'a, UART: uart::Instance, PIN: Pin> Client<'a, UART, PIN> { info!( "{} Fan answered the read with {:?}: {:?}", - fan_identifier, registers, response + device_identifier, registers, response ); Ok(registers) @@ -473,26 +592,29 @@ impl<'a, UART: uart::Instance, PIN: Pin> Client<'a, UART, PIN> { async fn send_request( &mut self, request: &[u8], - fan_identifier: &str, + device_identifier: &str, ) -> Result<(), ExchangeError> { // Write then read // Set pin setting DE (driver enable) to on (high) on the MAX485 to send data self.driver_enable.set_high(); - info!("{} Sending message to fan: {:?}", fan_identifier, request); + info!( + "{} Sending message to device: {:?}", + device_identifier, request + ); // As ref because &[u8; 8] is not the same as &[u8] with_timeout(configuration::FAN_TIMEOUT, self.uart.write_all(request)) .await .map_err(|_timeout| ExchangeError::Request(SendFailure::Timeout))? .map_err(|_error| ExchangeError::Request(SendFailure::Uart))?; - info!("{} Request written", fan_identifier); + info!("{} Request written", device_identifier); // Flushing only drains the software buffer, which empties as soon as the interrupt handler // has moved the frame into the hardware FIFO. At that point none of it has reached the wire let result = self.uart.blocking_flush(); if let Err(_error) = result { - error!("{} UART flush error", fan_identifier); + error!("{} UART flush error", device_identifier); } // So wait for the transmitter itself to go idle. BUSY stays asserted until the FIFO has @@ -524,9 +646,9 @@ impl<'a, UART: uart::Instance, PIN: Pin> Client<'a, UART, PIN> { &mut self, response: &mut [u8], request: &[u8], - fan_identifier: &str, + device_identifier: &str, ) -> Result { - info!("{} Waiting for response from fan", fan_identifier); + info!("{} Waiting for response from device", device_identifier); self.receive_exact(&mut response[..HEADER_LENGTH]) .await .map_err(|failure| ExchangeError::Response(Part::Header, failure))?; @@ -534,7 +656,7 @@ impl<'a, UART: uart::Instance, PIN: Pin> Client<'a, UART, PIN> { if response[0] != request[0] { warn!( "{} Response came from device address {:?} instead of {:?}", - fan_identifier, response[0], request[0] + device_identifier, response[0], request[0] ); return Err(ExchangeError::DeviceAddress(response[0])); } @@ -549,14 +671,14 @@ impl<'a, UART: uart::Instance, PIN: Pin> Client<'a, UART, PIN> { if !is_checksum_valid(frame) { warn!( "{} Exception response failed checksum: {:?}", - fan_identifier, frame + device_identifier, frame ); return Err(ExchangeError::ExceptionChecksum); } error!( - "{} Fan rejected function code {:?} with modbus exception code {:?}", - fan_identifier, function_code, response[2] + "{} Device rejected function code {:?} with modbus exception code {:?}", + device_identifier, function_code, response[2] ); return Ok(Answer::Exception(response[2])); } @@ -564,7 +686,7 @@ impl<'a, UART: uart::Instance, PIN: Pin> Client<'a, UART, PIN> { if response[1] != function_code { warn!( "{} Response used function code {:?} instead of {:?}", - fan_identifier, response[1], function_code + device_identifier, response[1], function_code ); return Err(ExchangeError::FunctionCode(response[1])); } @@ -586,19 +708,19 @@ impl<'a, UART: uart::Instance, PIN: Pin> Client<'a, UART, PIN> { /// Reads until the line has been silent for [`DISCARD_TIMEOUT`] to drop a partial or /// unexpected frame before the next transaction starts - async fn discard_incoming(&mut self, fan_identifier: &str) { + async fn discard_incoming(&mut self, device_identifier: &str) { let mut discarded = [0u8; WRITE_RESPONSE_LENGTH]; while let Ok(result) = with_timeout(DISCARD_TIMEOUT, self.uart.read(&mut discarded)).await { match result { Ok(0) => break, Ok(count) => info!( "{} Discarded {:?} unexpected bytes: {:?}", - fan_identifier, + device_identifier, count, &discarded[..count] ), Err(_error) => { - error!("{} UART error while clearing the line", fan_identifier); + error!("{} UART error while clearing the line", device_identifier); break; } } diff --git a/fan-controller/src/modbus/coil.rs b/fan-controller/src/modbus/coil.rs new file mode 100644 index 0000000..726d43d --- /dev/null +++ b/fan-controller/src/modbus/coil.rs @@ -0,0 +1,41 @@ +//! A coil lives in its own address space, separate from the registers. On the bypass relay the +//! two overlap in a way worth keeping apart at the type level: coil `0x0000` is the relay itself, +//! while holding register `0x0000` is the device address of the whole module + +// Allowed until the routine that drives the bypass is the caller +#![allow(dead_code)] + +mod address { + use core::ops::Deref; + + #[derive(Debug, Clone, Copy)] + pub(crate) struct Address(u16); + + impl Address { + pub(crate) const fn new(value: u16) -> Self { + Self(value) + } + } + + impl From
for u16 { + fn from(value: Address) -> Self { + value.0 + } + } + + impl From for Address { + fn from(value: u16) -> Self { + Self(value) + } + } + + impl Deref for Address { + type Target = u16; + + fn deref(&self) -> &Self::Target { + &self.0 + } + } +} + +pub(crate) use address::Address; diff --git a/fan-controller/src/modbus/function/code.rs b/fan-controller/src/modbus/function/code.rs index 981f8d9..b237a15 100644 --- a/fan-controller/src/modbus/function/code.rs +++ b/fan-controller/src/modbus/function/code.rs @@ -1,7 +1,19 @@ +/// Reads what a coil is currently set to. Coils are the single bit equivalent of the holding +/// registers, which is how the bypass relay reports and takes its position +// Allowed until the routine that drives the bypass is the caller +#[allow(dead_code)] +pub const READ_COILS: u8 = 0x01; + pub const READ_HOLDING_REGISTERS: u8 = 0x03; pub const READ_INPUT_REGISTERS: u8 = 0x04; +/// Drives a single coil. The value is not a zero or a one: modbus spells the two positions +/// `0xFF00` and `0x0000`, and nothing else is allowed +// Allowed until the routine that drives the bypass is the caller +#[allow(dead_code)] +pub const WRITE_SINGLE_COIL: u8 = 0x05; + pub const WRITE_SINGLE_REGISTER: u8 = 0x06; /// A device reports an error by responding with the function code of the request and this bit set diff --git a/fan-controller/src/modbus/function/mod.rs b/fan-controller/src/modbus/function/mod.rs index 43f5a55..b4d0d6a 100644 --- a/fan-controller/src/modbus/function/mod.rs +++ b/fan-controller/src/modbus/function/mod.rs @@ -1,8 +1,12 @@ pub(super) mod code; +pub(crate) mod read_coil; pub(crate) mod read_holding_register; pub(crate) mod read_input_register; pub(crate) mod write_holding_register; +pub(crate) mod write_single_coil; +pub(crate) use read_coil::ReadCoil; pub(crate) use read_holding_register::ReadHoldingRegister; pub(crate) use read_input_register::ReadInputRegisters; pub(crate) use write_holding_register::WriteHoldingRegister; +pub(crate) use write_single_coil::WriteSingleCoil; diff --git a/fan-controller/src/modbus/function/read_coil.rs b/fan-controller/src/modbus/function/read_coil.rs new file mode 100644 index 0000000..b16f47a --- /dev/null +++ b/fan-controller/src/modbus/function/read_coil.rs @@ -0,0 +1,52 @@ +// Allowed until the routine that drives the bypass is the caller +#![allow(dead_code)] + +use crate::modbus; + +/// Reads a single coil. Modbus answers a coil read with a run of bits packed into bytes, so asking +/// for exactly one keeps the answer to a single data byte with the position in its lowest bit, the +/// same way [`super::ReadHoldingRegister`] asks for exactly one register. +/// See Alssay single-way Modbus relay module LC-Modbus-1R-D7, section 3, instruction 8 +pub(crate) struct ReadCoil([u8; 8]); + +impl ReadCoil { + /// How many coils to read. The relay has eight and answers for all of them in one byte, but + /// only the first is wired to anything, and a shorter answer is one less thing to decode + const COUNT: u16 = 1; + + pub(crate) fn new( + device_address: modbus::device::Address, + coil_address: modbus::coil::Address, + ) -> Self { + let coil_address = coil_address.to_be_bytes(); + let count = Self::COUNT.to_be_bytes(); + let mut data = [ + *device_address, + modbus::function::code::READ_COILS, + coil_address[0], + coil_address[1], + count[0], + count[1], + // CRC set in next step + 0, + 0, + ]; + + let checksum = modbus::CRC.checksum(&data[..6]).to_be_bytes(); + + // They come out reversed (or is us using to_be_bytes reversed?) + data[6] = checksum[1]; + data[7] = checksum[0]; + Self(data) + } + + pub(crate) fn device_address(&self) -> modbus::device::Address { + self.0[0].into() + } +} + +impl AsRef<[u8]> for ReadCoil { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} diff --git a/fan-controller/src/modbus/function/write_single_coil.rs b/fan-controller/src/modbus/function/write_single_coil.rs new file mode 100644 index 0000000..7d53e35 --- /dev/null +++ b/fan-controller/src/modbus/function/write_single_coil.rs @@ -0,0 +1,56 @@ +// Allowed until the routine that drives the bypass is the caller +#![allow(dead_code)] + +use crate::modbus; + +/// Drives a single coil, which is how the bypass relay is opened and closed. +/// The frame is the same shape as [`super::WriteHoldingRegister`] and the response is the same +/// echo of the request, so the client reads both of them the same way. +/// See Alssay single-way Modbus relay module LC-Modbus-1R-D7, section 3, instructions 1 and 2 +pub(crate) struct WriteSingleCoil([u8; 8]); + +/// What the two positions of a coil are spelled as. Modbus does not use zero and one here, and a +/// device is free to refuse anything else +mod value { + pub(super) const ON: u16 = 0xFF00; + pub(super) const OFF: u16 = 0x0000; +} + +impl WriteSingleCoil { + pub(crate) fn new( + device_address: modbus::device::Address, + coil_address: modbus::coil::Address, + is_on: bool, + ) -> Self { + let coil_address = coil_address.to_be_bytes(); + let value = if is_on { value::ON } else { value::OFF }; + let mut data = [ + *device_address, + modbus::function::code::WRITE_SINGLE_COIL, + coil_address[0], + coil_address[1], + (value >> 8) as u8, + value as u8, + // CRC set in next step + 0, + 0, + ]; + + let checksum = modbus::CRC.checksum(&data[..6]).to_be_bytes(); + + // They come out reversed (or is us using to_be_bytes reversed?) + data[6] = checksum[1]; + data[7] = checksum[0]; + Self(data) + } + + pub(crate) fn device_address(&self) -> modbus::device::Address { + self.0[0].into() + } +} + +impl AsRef<[u8]> for WriteSingleCoil { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} diff --git a/fan-controller/src/modbus/mod.rs b/fan-controller/src/modbus/mod.rs index c295896..f1ddb88 100644 --- a/fan-controller/src/modbus/mod.rs +++ b/fan-controller/src/modbus/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod client; +pub(crate) mod coil; pub(crate) mod device; pub(crate) mod function; pub(crate) mod register; -- 2.51.2