r413d08_lib/protocol.rs
1//! Defines data structures, constants, and protocol logic for interacting
2//! with an 8-Channel Multifunction RS485 Module via Modbus RTU, based on
3//! the protocol specification document.
4//!
5//! This module covers:
6//! - Representing port states ([`PortState`], [`PortStates`]).
7//! - Identifying specific ports ([`Port`]) and the Modbus device address ([`Address`]).
8//! - Defining Modbus register addresses and data values for reading states and controlling ports.
9//! - Encoding and decoding values from/to Modbus register format ([`Word`]).
10//! - Error handling for invalid port or address values.
11//!
12//! Assumes standard Modbus function codes "Read Holding Registers" (0x03) and
13//! "Write Single Register" (0x06) are used externally to interact with the device.
14
15use thiserror::Error;
16
17/// A comprehensive error type for all operations within the `protocol` module.
18///
19/// This enum consolidates errors that can occur during the decoding
20/// data, encoding of values, or validation of parameters.
21#[derive(Error, Debug, PartialEq)]
22pub enum Error {
23 /// Error indicating that the data received from a Modbus read has an unexpected length.
24 #[error("Invalid data length: expected {expected}, got {got}")]
25 UnexpectedDataLength { expected: usize, got: usize },
26
27 /// Error for malformed data within a register, e.g., an unexpected non-zero upper byte.
28 #[error("Invalid data in register: {details}")]
29 InvalidData { details: String },
30
31 /// Error for an invalid value read from a register, e.g., an undefined baud rate code.
32 #[error("Invalid value code for {entity}: {code}")]
33 InvalidValueCode { entity: String, code: u16 },
34
35 /// Error for an attempt to encode a value that is not supported, e.g., a `NaN` temperature.
36 #[error("Cannot encode value: {reason}")]
37 EncodeError { reason: String },
38}
39
40/// Represents a single 16-bit value stored in a Modbus register.
41///
42/// Modbus RTU typically operates on 16-bit registers.
43pub type Word = u16;
44
45/// The total number of controllable digital I/O ports on the module.
46pub const NUMBER_OF_PORTS: usize = 8;
47
48/// Represents the state of a single digital I/O port (e.g., relay).
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
50#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
51pub enum PortState {
52 /// The port is inactive/relay OFF.
53 Close,
54 /// The port is active/relay ON.
55 Open,
56}
57
58impl PortState {
59 /// Decodes a [`PortState`] from a single Modbus holding register value (`Word`).
60 ///
61 /// # Arguments
62 ///
63 /// * `word`: The [`Word`] (u16) value read from the Modbus register for this port.
64 ///
65 /// # Returns
66 ///
67 /// The corresponding [`PortState`].
68 ///
69 /// # Example
70 /// ```
71 /// # use r413d08_lib::protocol::PortState;
72 /// assert_eq!(PortState::decode_from_holding_registers(0x0000), PortState::Close);
73 /// assert_eq!(PortState::decode_from_holding_registers(0x0001), PortState::Open);
74 /// ```
75 pub fn decode_from_holding_registers(word: Word) -> Self {
76 // According to the device protocol document:
77 // - `0x0000` represents [`PortState::Close`].
78 // - `0x0001` (and likely any non-zero value) represents [`PortState::Open`].
79 if word != 0 {
80 PortState::Open
81 } else {
82 PortState::Close
83 }
84 }
85}
86
87/// Provides a human-readable string representation ("close" or "open").
88impl std::fmt::Display for PortState {
89 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90 match self {
91 Self::Close => write!(f, "close"),
92 Self::Open => write!(f, "open"),
93 }
94 }
95}
96
97/// Represents the collective states of all [`NUMBER_OF_PORTS`] ports.
98///
99/// This struct holds an array of [`PortState`] and provides constants
100/// for reading all port states using a single Modbus "Read Holding Registers" (0x03) command.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
102#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
103pub struct PortStates([PortState; NUMBER_OF_PORTS]);
104
105impl PortStates {
106 /// The Modbus function code 0x03 (Read Holding Registers) register address used for reading all port states sequentially.
107 pub const ADDRESS: u16 = 0x0001;
108 /// The number of consecutive Modbus registers (`Word`s) to read to get all port states.
109 ///
110 /// This corresponds to [`NUMBER_OF_PORTS`].
111 pub const QUANTITY: u16 = NUMBER_OF_PORTS as u16;
112
113 /// Decodes the states of all ports from a slice of Modbus holding register values.
114 ///
115 /// Expects `words` to contain [`NUMBER_OF_PORTS`] values. Each word in the slice
116 /// corresponds to the state of a single port, decoded via [`PortState::decode_from_holding_registers`].
117 ///
118 /// If `words` contains fewer than [`NUMBER_OF_PORTS`] items, the remaining
119 /// port states in the returned struct will retain their default initialized value (`PortState::Close`).
120 /// Extra items in `words` beyond [`NUMBER_OF_PORTS`] are ignored.
121 ///
122 /// # Arguments
123 ///
124 /// * `words`: A slice of [`Word`] containing the register values read from the device.
125 ///
126 /// # Returns
127 ///
128 /// A [`PortStates`] struct containing the decoded state for each port.
129 ///
130 /// This function is robust against malformed data:
131 /// - If `words` contains fewer than [`NUMBER_OF_PORTS`] items, the remaining
132 /// port states default to [`PortState::Close`].
133 /// - If `words` contains more than [`NUMBER_OF_PORTS`] items, the extra
134 /// items are ignored.
135 ///
136 /// # Example
137 /// ```
138 /// # use r413d08_lib::protocol::{PortState, PortStates, Word, NUMBER_OF_PORTS};
139 /// // Example data mimicking a Modbus read response for 8 registers
140 /// let modbus_data: [Word; NUMBER_OF_PORTS] = [0x1, 0x0, 0xFFFF, 0x0, 0x0, 0x0, 0x1234, 0x0];
141 /// let decoded_states = PortStates::decode_from_holding_registers(&modbus_data);
142 /// assert_eq!(decoded_states.as_array()[0], PortState::Open);
143 /// assert_eq!(decoded_states.as_array()[1], PortState::Close);
144 /// assert_eq!(decoded_states.as_array()[2], PortState::Open); // Non-zero treated as Open
145 /// // ... and so on for all ports
146 /// ```
147 pub fn decode_from_holding_registers(words: &[Word]) -> Self {
148 let mut port_states = [PortState::Close; NUMBER_OF_PORTS];
149 // Iterate over the words read, up to the number of ports we have storage for.
150 for (i, word) in words.iter().enumerate().take(NUMBER_OF_PORTS) {
151 port_states[i] = PortState::decode_from_holding_registers(*word);
152 }
153 Self(port_states)
154 }
155
156 /// Returns an iterator over the individual [`PortState`] values in the order of the ports.
157 pub fn iter(&self) -> std::slice::Iter<'_, PortState> {
158 self.0.iter()
159 }
160
161 /// Returns a slice containing all `Temperature` values.
162 pub fn as_slice(&self) -> &[PortState] {
163 &self.0
164 }
165
166 /// Provides direct access to the underlying array of port states.
167 pub fn as_array(&self) -> &[PortState; NUMBER_OF_PORTS] {
168 &self.0
169 }
170}
171
172impl IntoIterator for PortStates {
173 type Item = PortState;
174 type IntoIter = std::array::IntoIter<PortState, NUMBER_OF_PORTS>;
175
176 fn into_iter(self) -> Self::IntoIter {
177 self.0.into_iter()
178 }
179}
180
181impl<'a> IntoIterator for &'a PortStates {
182 type Item = &'a PortState;
183 type IntoIter = std::slice::Iter<'a, PortState>;
184
185 fn into_iter(self) -> Self::IntoIter {
186 self.0.iter()
187 }
188}
189
190/// Provides a comma-separated string representation of all port states (e.g., "close, open, close, ...").
191impl std::fmt::Display for PortStates {
192 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
193 let p_strs: Vec<String> = self.0.iter().map(|t| t.to_string()).collect();
194 write!(f, "{}", p_strs.join(", "))
195 }
196}
197
198impl std::ops::Index<usize> for PortStates {
199 type Output = PortState;
200
201 /// Allows indexing into the port states array.
202 ///
203 /// # Panics
204 /// Panics if the index is out of bounds (0-7).
205 fn index(&self, index: usize) -> &Self::Output {
206 &self.0[index]
207 }
208}
209
210/// Represents a validated port index, guaranteed to be within the valid range `0..NUMBER_OF_PORTS`.
211///
212/// Use [`Port::try_from`] to create an instance from a `u8`.
213/// This struct also defines constants for the *data values* used when controlling a specific port via
214/// Modbus "Write Single Register" (function code 0x06). The register *address*
215/// corresponds to the 1-based port index (see [`Port::address_for_write_register`]).
216#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
217#[cfg_attr(feature = "serde", derive(serde::Serialize))]
218pub struct Port(u8); // Internally stores 0-based index
219
220#[cfg(feature = "serde")]
221impl<'de> serde::Deserialize<'de> for Port {
222 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
223 where
224 D: serde::Deserializer<'de>,
225 {
226 let value = u8::deserialize(deserializer)?;
227 Port::try_from(value).map_err(serde::de::Error::custom)
228 }
229}
230
231impl Port {
232 /// The minimum valid port index (inclusive).
233 pub const MIN: u8 = 0;
234 /// The maximum valid port index (inclusive).
235 pub const MAX: u8 = NUMBER_OF_PORTS as u8 - 1;
236
237 // --- Modbus Register Data Values for Port Control ---
238 // These constant `Word` values are written to the specific port's Modbus register address
239 // using Modbus function code 0x06 (Write Single Register).
240
241 /// Register data value to **open** the specified port (turn relay ON / activate output).
242 pub const REG_DATA_SET_PORT_OPEN: Word = 0x0100;
243 /// Register data value to **close** the specified port (turn relay OFF / deactivate output).
244 pub const REG_DATA_SET_PORT_CLOSE: Word = 0x0200;
245 /// Register data value to **toggle** the state of the specified port (Open <-> Close). Also called "Self-locking".
246 pub const REG_DATA_SET_PORT_TOGGLE: Word = 0x0300;
247 /// Register data value to **latch** the specified port (set this port Open, set all others Close). Also called "Inter-locking".
248 pub const REG_DATA_SET_PORT_LATCH: Word = 0x0400;
249 /// Register data value to activate the specified port **momentarily** (Open for ~1 second, then automatically Close). Also called "Non-locking".
250 pub const REG_DATA_SET_PORT_MOMENTARY: Word = 0x0500;
251 /// Base register data value to initiate a **delayed action** on the specified port.
252 /// The actual delay (0-255 seconds) must be added to this value using [`Port::encode_delay_for_write_register`].
253 /// The action is typically Open -> wait delay -> Close.
254 pub const REG_DATA_SET_PORT_DELAY: Word = 0x0600;
255
256 /// Returns the Modbus register address for controlling this specific port.
257 ///
258 /// The address is used with Modbus function 0x06 (Write Single Register), where the
259 /// *data* written to this address determines the action (e.g., [`Port::REG_DATA_SET_PORT_OPEN`]).
260 ///
261 /// # Returns
262 ///
263 /// The `u16` Modbus address for controlling this port.
264 ///
265 /// # Example
266 /// ```
267 /// # use r413d08_lib::protocol::Port;
268 /// assert_eq!(Port::try_from(0).unwrap().address_for_write_register(), 0x0001);
269 /// assert_eq!(Port::try_from(7).unwrap().address_for_write_register(), 0x0008);
270 /// ```
271 pub fn address_for_write_register(&self) -> u16 {
272 // Add 1 to the 0-based port index to get the 1-based Modbus address.
273 (self.0 + 1) as u16
274 }
275
276 /// Encodes the register data value (`Word`) for setting a delayed action on a port.
277 ///
278 /// This combines the command code [`Port::REG_DATA_SET_PORT_DELAY`] (in the high byte)
279 /// with the desired delay duration (in the low byte). The resulting `Word` should be written
280 /// to the port's specific address (see [`Port::address_for_write_register`]) using
281 /// Modbus function 0x06.
282 ///
283 /// # Arguments
284 ///
285 /// * `delay`: The delay duration in seconds.
286 ///
287 /// # Returns
288 ///
289 /// The corresponding `Word` to be written to the register for the delayed action command.
290 ///
291 /// # Example
292 /// ```
293 /// # use r413d08_lib::protocol::{Port, Word};
294 /// // Command data to trigger a delayed action after 10 seconds:
295 /// let delay_command_data = Port::encode_delay_for_write_register(10);
296 /// assert_eq!(delay_command_data, 0x060A); // 0x0600 + 10
297 ///
298 /// // This value (0x060A) would then be written to the target port's Modbus address.
299 /// // e.g., for port 3 (address 0x0004): WriteRegister(address=4, value=0x060A)
300 /// ```
301 pub fn encode_delay_for_write_register(delay: u8) -> Word {
302 // Adds the delay (lower byte) to the command code (upper byte).
303 Self::REG_DATA_SET_PORT_DELAY + (delay as Word)
304 }
305}
306
307/// Allows accessing the inner `u8` port index value directly.
308impl std::ops::Deref for Port {
309 type Target = u8;
310 fn deref(&self) -> &Self::Target {
311 &self.0
312 }
313}
314
315/// Error indicating that a provided port index (`u8`) is outside the valid range
316/// defined by [`Port::MIN`] and [`Port::MAX`] (inclusive).
317#[derive(Error, Debug, Clone, Copy, PartialEq, Eq)]
318#[error(
319 "The port value {0} is outside the valid range of {min} to {max}",
320 min = Port::MIN,
321 max = Port::MAX
322)]
323pub struct ErrorPortOutOfRange(
324 /// The invalid port value that caused the error.
325 pub u8,
326);
327
328impl TryFrom<u8> for Port {
329 type Error = ErrorPortOutOfRange;
330
331 /// Attempts to create a [`Port`] from a `u8` value, validating its 0-based range.
332 ///
333 /// # Arguments
334 ///
335 /// * `value`: The port index to validate.
336 ///
337 /// # Returns
338 ///
339 /// * `Ok(Port)`: If the `value` is within the valid range [[`Port::MIN`], [`Port::MAX`]].
340 /// * `Err(ErrorPortOutOfRange)`: If the `value` is outside the valid range.
341 ///
342 /// # Example
343 /// ```
344 /// # use r413d08_lib::protocol::{Port, ErrorPortOutOfRange, NUMBER_OF_PORTS};
345 /// let max_port_index = (NUMBER_OF_PORTS - 1) as u8; // Should be 7
346 /// assert!(Port::try_from(0).is_ok());
347 /// assert!(Port::try_from(max_port_index).is_ok());
348 /// let invalid_index = max_port_index + 1; // Should be 8
349 /// assert_eq!(Port::try_from(invalid_index).unwrap_err(), ErrorPortOutOfRange(invalid_index));
350 /// ```
351 fn try_from(value: u8) -> Result<Self, Self::Error> {
352 // Check if the value as usize is within the valid range constants.
353 if (Self::MIN..=Self::MAX).contains(&value) {
354 Ok(Self(value))
355 } else {
356 Err(ErrorPortOutOfRange(value))
357 }
358 }
359}
360
361impl std::fmt::Display for Port {
362 /// Formats the port as its number (e.g., "0", "7").
363 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
364 write!(f, "{}", self.0)
365 }
366}
367
368/// A zero-sized type providing constants for controlling all ports simultaneously.
369pub struct PortsAll;
370
371impl PortsAll {
372 /// The address is used with Modbus function 0x06 (Write Single Register) for commands that affect
373 /// all ports simultaneously, where the *data* that is written to this address determines the action
374 /// (e.g. [`PortsAll::REG_DATA_SET_ALL_OPEN`]).
375 pub const ADDRESS: u16 = 0x0000;
376
377 /// Register data value to **open all** ports simultaneously (turn all relays ON).
378 /// This value should be written to [`PortsAll::ADDRESS`].
379 pub const REG_DATA_SET_ALL_OPEN: Word = 0x0700;
380
381 /// Register data value to **close all** ports simultaneously (turn all relays OFF).
382 /// This value should be written to [`PortsAll::ADDRESS`].
383 pub const REG_DATA_SET_ALL_CLOSE: Word = 0x0800;
384}
385
386/// Represents a validated Modbus device address, used for RTU communication over RS485.
387///
388/// Valid addresses are in the range 1 to 247 (inclusive).
389/// Use [`Address::try_from`] to create an instance from a `u8`.
390/// Provides constants and methods for reading/writing the device address itself via Modbus commands.
391#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
392#[cfg_attr(feature = "serde", derive(serde::Serialize))]
393pub struct Address(u8);
394
395#[cfg(feature = "serde")]
396impl<'de> serde::Deserialize<'de> for Address {
397 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
398 where
399 D: serde::Deserializer<'de>,
400 {
401 let value = u8::deserialize(deserializer)?;
402 Address::try_from(value).map_err(serde::de::Error::custom)
403 }
404}
405
406/// Allows accessing the inner `u8` address value directly.
407impl std::ops::Deref for Address {
408 type Target = u8;
409 fn deref(&self) -> &Self::Target {
410 &self.0
411 }
412}
413
414impl Default for Address {
415 /// Returns the factory default Modbus address.
416 fn default() -> Self {
417 // Safe because `0x01` is within the valid range 1-247.
418 Self(0x01)
419 }
420}
421
422impl Address {
423 /// The Modbus register address used to read or write the device's own Modbus address.
424 ///
425 /// Use Modbus function 0x03 (Read Holding Registers) to read (requires addressing
426 /// the device with its *current* address or the broadcast address).
427 /// Use function 0x06 (Write Single Register) to change the address (also requires
428 /// addressing the device with its current address).
429 pub const ADDRESS: u16 = 0x00FF;
430
431 /// The number of registers to read when reading the device address.
432 pub const QUANTITY: u16 = 1;
433
434 /// The minimum valid assignable Modbus device address (inclusive).
435 pub const MIN: u8 = 1;
436 /// The maximum valid assignable Modbus device address (inclusive).
437 pub const MAX: u8 = 247;
438
439 /// The Modbus broadcast address (`0xFF` or 255).
440 ///
441 /// Can be used for reading the device address when it's unknown.
442 /// This address cannot be assigned to a device as its permanent address.
443 pub const BROADCAST: Address = Address(0xFF);
444
445 /// Decodes the device [`Address`] from a Modbus holding register value read from the device.
446 ///
447 /// Expects `words` to contain the single register value read from the device address
448 /// configuration register ([`Address::ADDRESS`]).
449 /// It validates that the decoded address is within the assignable range ([`Address::MIN`]..=[`Address::MAX`]).
450 ///
451 /// # Arguments
452 ///
453 /// * `words`: A slice containing the [`Word`] value read from the device address register. Expected to have length 1.
454 ///
455 /// # Returns
456 ///
457 /// An [`Address`] struct containing the decoded and validated value.
458 ///
459 /// # Panics
460 ///
461 /// Panics if:
462 /// 1. `words` is empty.
463 /// 2. The upper byte of the `Word` containing the address is non-zero, indicating unexpected data.
464 /// 3. The address value read from the register is outside the valid assignable range.
465 pub fn decode_from_holding_registers(words: &[Word]) -> Result<Self, Error> {
466 if words.len() != Self::QUANTITY as usize {
467 return Err(Error::UnexpectedDataLength {
468 expected: Self::QUANTITY as usize,
469 got: words.len(),
470 });
471 }
472 let word_value = words[0];
473
474 if word_value & 0xFF00 != 0 {
475 return Err(Error::InvalidData {
476 details: format!(
477 "Upper byte of address register is non-zero (value: {word_value:#06X})"
478 ),
479 });
480 }
481
482 let address_byte = word_value as u8;
483 match Self::try_from(address_byte) {
484 Ok(address) => Ok(address),
485 Err(err) => Err(Error::InvalidData {
486 details: format!("{err}"),
487 }),
488 }
489 }
490
491 /// Encodes the device [`Address`] into a [`Word`] value suitable for writing to the
492 /// device address configuration register ([`Address::ADDRESS`]) using Modbus function 0x06.
493 ///
494 /// # Returns
495 ///
496 /// The [`Word`] (u16) representation of the address value.
497 pub fn encode_for_write_register(&self) -> Word {
498 self.0 as u16
499 }
500}
501
502/// Error indicating that a provided Modbus device address (`u8`) is outside the valid range
503/// for assignable addresses, defined by [`Address::MIN`] and [`Address::MAX`] (inclusive).
504#[derive(Error, Debug, Clone, Copy, PartialEq, Eq)]
505#[error(
506 "The address value {0} is outside the valid assignable range of {min} to {max}",
507 min = Address::MIN,
508 max = Address::MAX
509)]
510pub struct ErrorAddressOutOfRange(
511 /// The invalid address value that caused the error.
512 pub u8,
513);
514
515impl TryFrom<u8> for Address {
516 type Error = ErrorAddressOutOfRange;
517
518 /// Attempts to create an [`Address`] from a `u8` value, validating its assignable range [[`Address::MIN`], [`Address::MAX`]].
519 ///
520 /// # Arguments
521 ///
522 /// * `value`: The Modbus address to validate.
523 ///
524 /// # Returns
525 ///
526 /// * `Ok(Address)`: If the `value` is within the valid assignable range [[`Address::MIN`], [`Address::MAX`]].
527 /// * `Err(ErrorAddressOutOfRange)`: If the `value` is outside the valid assignable range (e.g., 0 or > 247).
528 ///
529 /// # Example
530 /// ```
531 /// # use r413d08_lib::protocol::{Address, ErrorAddressOutOfRange};
532 /// assert!(Address::try_from(0).is_err());
533 /// assert!(Address::try_from(1).is_ok());
534 /// assert!(Address::try_from(247).is_ok());
535 /// assert_eq!(Address::try_from(248).unwrap_err(), ErrorAddressOutOfRange(248));
536 /// assert!(Address::try_from(255).is_err()); // Broadcast address is not valid for TryFrom
537 /// ```
538 fn try_from(value: u8) -> Result<Self, Self::Error> {
539 if (Self::MIN..=Self::MAX).contains(&value) {
540 Ok(Self(value))
541 } else {
542 Err(ErrorAddressOutOfRange(value))
543 }
544 }
545}
546
547/// Provides a hexadecimal string representation (e.g., "0x01", "0xf7").
548impl std::fmt::Display for Address {
549 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
550 write!(f, "{:#04x}", self.0)
551 }
552}
553
554#[cfg(test)]
555mod tests {
556 use super::*;
557 use assert_matches::assert_matches;
558
559 // --- Address Tests ---
560 #[test]
561 fn address_try_from_validation() {
562 assert!(matches!(
563 Address::try_from(0),
564 Err(ErrorAddressOutOfRange(0))
565 ));
566 assert!(matches!(Address::try_from(Address::MIN), Ok(Address(1))));
567 assert!(matches!(Address::try_from(Address::MAX), Ok(Address(247))));
568 assert!(matches!(
569 Address::try_from(Address::MAX + 1),
570 Err(ErrorAddressOutOfRange(248))
571 ));
572 assert!(matches!(
573 Address::try_from(255),
574 Err(ErrorAddressOutOfRange(255))
575 ));
576 assert!(matches!(Address::try_from(100), Ok(Address(100))));
577 }
578
579 #[test]
580 fn address_default() {
581 assert_eq!(Address::default(), Address(1));
582 }
583
584 #[test]
585 fn address_encode_decode() {
586 let addr = Address::try_from(42).unwrap();
587 let encoded = addr.encode_for_write_register();
588 assert_eq!(encoded, 42u16);
589 // Test decode (assumes valid input from register)
590 let decoded = Address::decode_from_holding_registers(&[encoded]).unwrap();
591 assert_eq!(decoded, addr);
592 }
593
594 #[test]
595 fn address_decode_panics_on_empty() {
596 assert_matches!(Address::decode_from_holding_registers(&[]), Err(..));
597 }
598
599 #[test]
600 fn address_decode_panics_on_invalid_value_zero() {
601 // 0 is outside the valid 1-247 range
602 assert_matches!(Address::decode_from_holding_registers(&[0x0000]), Err(..));
603 }
604
605 #[test]
606 fn address_decode_panics_on_invalid_value_high() {
607 // 248 is outside the valid 1-247 range
608 assert_matches!(Address::decode_from_holding_registers(&[0x00F8]), Err(..));
609 }
610
611 #[test]
612 fn address_decode_valid() {
613 assert_eq!(
614 Address::decode_from_holding_registers(&[0x0001]),
615 Ok(Address(1))
616 );
617 assert_eq!(
618 Address::decode_from_holding_registers(&[0x00F7]),
619 Ok(Address(247))
620 );
621 }
622
623 // --- Port Tests ---
624 #[test]
625 fn port_try_from_validation() {
626 assert!(matches!(Port::try_from(Port::MIN), Ok(Port(0))));
627 assert!(matches!(Port::try_from(Port::MAX), Ok(Port(7))));
628 assert!(matches!(
629 Port::try_from(Port::MAX + 1),
630 Err(ErrorPortOutOfRange(8))
631 ));
632 assert!(matches!(Port::try_from(3), Ok(Port(3))));
633 }
634
635 #[test]
636 fn port_address_for_write_register_is_one_based() {
637 // Check if address is 1-based according to documentation
638 assert_eq!(Port::try_from(0).unwrap().address_for_write_register(), 1); // Port 0 -> Address 1
639 assert_eq!(Port::try_from(1).unwrap().address_for_write_register(), 2); // Port 1 -> Address 2
640 assert_eq!(Port::try_from(7).unwrap().address_for_write_register(), 8); // Port 7 -> Address 8
641 }
642
643 #[test]
644 fn port_encode_delay() {
645 assert_eq!(Port::encode_delay_for_write_register(0), 0x0600);
646 assert_eq!(Port::encode_delay_for_write_register(10), 0x060A);
647 assert_eq!(Port::encode_delay_for_write_register(255), 0x06FF);
648 }
649
650 // --- PortState / PortStates Tests ---
651 #[test]
652 fn port_state_decode() {
653 assert_eq!(
654 PortState::decode_from_holding_registers(0x0000),
655 PortState::Close
656 );
657 assert_eq!(
658 PortState::decode_from_holding_registers(0x0001),
659 PortState::Open
660 );
661 assert_eq!(
662 PortState::decode_from_holding_registers(0xFFFF),
663 PortState::Open
664 );
665 }
666
667 #[test]
668 fn port_states_decode() {
669 // Test case 1: Correct number of words, all closed
670 let words_all_closed = [0x0000; NUMBER_OF_PORTS];
671 let expected_all_closed = PortStates([PortState::Close; NUMBER_OF_PORTS]);
672 assert_eq!(
673 PortStates::decode_from_holding_registers(&words_all_closed),
674 expected_all_closed,
675 "Should decode all-closed states correctly"
676 );
677
678 // Test case 2: Correct number of words, mixed states
679 let words_mixed = [
680 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000,
681 ];
682 let expected_mixed = PortStates([
683 PortState::Open,
684 PortState::Close,
685 PortState::Open,
686 PortState::Close,
687 PortState::Open,
688 PortState::Close,
689 PortState::Open,
690 PortState::Close,
691 ]);
692 assert_eq!(
693 PortStates::decode_from_holding_registers(&words_mixed),
694 expected_mixed,
695 "Should decode mixed states correctly"
696 );
697
698 // Test case 3: Fewer words than expected (short read)
699 let words_short = [0x0001, 0x0000]; // Only 2 words
700 let mut expected_short_arr = [PortState::Close; NUMBER_OF_PORTS];
701 expected_short_arr[0] = PortState::Open;
702 expected_short_arr[1] = PortState::Close;
703 let expected_short = PortStates(expected_short_arr);
704 assert_eq!(
705 PortStates::decode_from_holding_registers(&words_short),
706 expected_short,
707 "Should handle short reads by filling with Close"
708 );
709
710 // Test case 4: More words than expected (long read)
711 let words_long = [
712 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x9999, 0x8888,
713 ]; // 10 words
714 let expected_long = PortStates([
715 PortState::Close,
716 PortState::Open,
717 PortState::Close,
718 PortState::Open,
719 PortState::Close,
720 PortState::Open,
721 PortState::Close,
722 PortState::Open,
723 ]);
724 assert_eq!(
725 PortStates::decode_from_holding_registers(&words_long),
726 expected_long,
727 "Should handle long reads by ignoring extra words"
728 );
729
730 // Test case 5: Empty slice
731 let words_empty: [Word; 0] = [];
732 let expected_empty = PortStates([PortState::Close; NUMBER_OF_PORTS]);
733 assert_eq!(
734 PortStates::decode_from_holding_registers(&words_empty),
735 expected_empty,
736 "Should handle empty slice by returning all-closed"
737 );
738 }
739
740 // --- Display Tests ---
741 #[test]
742 fn display_formats() {
743 assert_eq!(PortState::Open.to_string(), "open");
744 assert_eq!(PortState::Close.to_string(), "close");
745 assert_eq!(Address(1).to_string(), "0x01");
746 assert_eq!(Address(247).to_string(), "0xf7");
747 assert_eq!(Address::BROADCAST.to_string(), "0xff"); // Direct creation
748 let states = PortStates([
749 PortState::Open,
750 PortState::Close,
751 PortState::Open,
752 PortState::Close,
753 PortState::Close,
754 PortState::Close,
755 PortState::Close,
756 PortState::Close,
757 ]);
758 assert_eq!(
759 states.to_string(),
760 "open, close, open, close, close, close, close, close"
761 );
762 }
763}