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