Skip to main content

snap7_client/
tag.rs

1use crate::proto::s7::header::{Area, TransportSize};
2
3use crate::error::{Error, Result};
4
5#[derive(Debug, Clone)]
6pub struct TagAddress {
7    pub area: Area,
8    pub db_number: u16,
9    pub byte_offset: u32,
10    pub bit_offset: u8,
11    pub transport: TransportSize,
12    pub element_count: u16,
13}
14
15pub fn parse_tag(tag: &str) -> Result<TagAddress> {
16    // Accept both "DB170,REAL262" and "DB170.REAL262" as separators.
17    // The dot separator is only valid between the area (DB\d+) and the type;
18    // normalize it to a comma so the rest of the parser is uniform.
19    let normalized: std::borrow::Cow<str> = if tag.contains(',') {
20        std::borrow::Cow::Borrowed(tag)
21    } else {
22        // Find first '.' that is preceded by a digit (end of DB number) and
23        // followed by a letter (start of type name), then replace it with ','.
24        let bytes = tag.as_bytes();
25        let sep = bytes.windows(2).enumerate().find_map(|(i, w)| {
26            if w[0].is_ascii_digit() && w[1].is_ascii_alphabetic() {
27                Some(i + 1) // position of the '.' we want to replace
28            } else {
29                None
30            }
31        });
32        // Only replace if the character at that position is actually '.'
33        if let Some(pos) = sep {
34            if bytes.get(pos) == Some(&b'.') {
35                let mut s = tag.to_string();
36                s.replace_range(pos..pos + 1, ",");
37                std::borrow::Cow::Owned(s)
38            } else {
39                std::borrow::Cow::Borrowed(tag)
40            }
41        } else {
42            std::borrow::Cow::Borrowed(tag)
43        }
44    };
45
46    let parts: Vec<&str> = normalized.splitn(2, ',').collect();
47    if parts.len() != 2 {
48        return Err(Error::PlcError {
49            code: 0,
50            message: format!("invalid tag: {}", tag),
51        });
52    }
53    let area_part = parts[0].to_uppercase();
54    let type_part = parts[1].to_uppercase();
55
56    let (area, db_number) = if let Some(rest) = area_part.strip_prefix("DB") {
57        let n: u16 = rest.parse().map_err(|_| Error::PlcError {
58            code: 0,
59            message: format!("invalid DB number in tag: {}", tag),
60        })?;
61        (Area::DataBlock, n)
62    } else {
63        return Err(Error::PlcError {
64            code: 0,
65            message: format!("unsupported area in tag: {}", tag),
66        });
67    };
68
69    // Support bit access format: DB70,332.0 (byte 332, bit 0)
70    // type_part is already uppercased from line 24
71    // For bit access, type_part starts with a digit (byte offset)
72    if type_part.starts_with(|c: char| c.is_ascii_digit()) {
73        let bits: Vec<&str> = type_part.split('.').collect();
74        if bits.len() == 2 {
75            let byte_offset: u32 = bits[0].parse().map_err(|_| Error::PlcError {
76                code: 0,
77                message: format!("invalid byte offset in tag: {}", tag),
78            })?;
79            let bit_offset: u8 = bits[1].parse().map_err(|_| Error::PlcError {
80                code: 0,
81                message: format!("invalid bit offset in tag: {}", tag),
82            })?;
83            if bit_offset > 7 {
84                return Err(Error::PlcError {
85                    code: 0,
86                    message: format!("bit offset must be 0-7: {}", tag),
87                });
88            }
89            return Ok(TagAddress {
90                area,
91                db_number,
92                byte_offset,
93                bit_offset,
94                transport: TransportSize::Bit,
95                element_count: 1,
96            });
97        }
98    }
99
100    let (transport, byte_offset) = if let Some(rest) = type_part.strip_prefix("REAL") {
101        (TransportSize::Real, rest.parse().unwrap_or(0))
102    } else if let Some(rest) = type_part.strip_prefix("DWORD") {
103        (TransportSize::DWord, rest.parse().unwrap_or(0))
104    } else if let Some(rest) = type_part.strip_prefix("DINT") {
105        (TransportSize::DInt, rest.parse().unwrap_or(0))
106    } else if let Some(rest) = type_part.strip_prefix("WORD") {
107        (TransportSize::Word, rest.parse().unwrap_or(0))
108    } else if let Some(rest) = type_part.strip_prefix("INT") {
109        (TransportSize::Int, rest.parse().unwrap_or(0))
110    } else if let Some(rest) = type_part.strip_prefix("BYTE") {
111        (TransportSize::Byte, rest.parse().unwrap_or(0))
112    } else {
113        return Err(Error::PlcError {
114            code: 0,
115            message: format!("unsupported type in tag: {}", tag),
116        });
117    };
118
119    Ok(TagAddress {
120        area,
121        db_number,
122        byte_offset,
123        bit_offset: 0,
124        transport,
125        element_count: 1,
126    })
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn parse_db_real() {
135        let tag = parse_tag("DB1,REAL4").unwrap();
136        assert_eq!(tag.db_number, 1);
137        assert_eq!(tag.byte_offset, 4);
138        assert_eq!(tag.transport, TransportSize::Real);
139    }
140
141    #[test]
142    fn parse_db_word() {
143        let tag = parse_tag("DB2,WORD10").unwrap();
144        assert_eq!(tag.db_number, 2);
145        assert_eq!(tag.byte_offset, 10);
146        assert_eq!(tag.transport, TransportSize::Word);
147    }
148
149    #[test]
150    fn parse_db_dint() {
151        let tag = parse_tag("DB70,DINT0").unwrap();
152        assert_eq!(tag.db_number, 70);
153        assert_eq!(tag.byte_offset, 0);
154        assert_eq!(tag.transport, TransportSize::DInt);
155        assert_eq!(tag.bit_offset, 0);
156    }
157
158    #[test]
159    fn parse_db_bit_access() {
160        let tag = parse_tag("DB70,332.0").unwrap();
161        assert_eq!(tag.db_number, 70);
162        assert_eq!(tag.byte_offset, 332);
163        assert_eq!(tag.bit_offset, 0);
164        assert_eq!(tag.transport, TransportSize::Bit);
165    }
166
167    #[test]
168    fn parse_db_bit_access_bit7() {
169        let tag = parse_tag("DB70,332.7").unwrap();
170        assert_eq!(tag.db_number, 70);
171        assert_eq!(tag.byte_offset, 332);
172        assert_eq!(tag.bit_offset, 7);
173        assert_eq!(tag.transport, TransportSize::Bit);
174    }
175
176    #[test]
177    fn parse_db_bit_invalid_bit() {
178        let result = parse_tag("DB70,332.8");
179        assert!(result.is_err());
180    }
181
182    #[test]
183    fn parse_invalid_returns_err() {
184        assert!(parse_tag("NOTVALID").is_err());
185    }
186
187    #[test]
188    fn parse_dot_separator_real() {
189        let tag = parse_tag("DB170.REAL262").unwrap();
190        assert_eq!(tag.db_number, 170);
191        assert_eq!(tag.byte_offset, 262);
192        assert_eq!(tag.transport, TransportSize::Real);
193    }
194
195    #[test]
196    fn parse_dot_separator_word() {
197        let tag = parse_tag("DB1.WORD10").unwrap();
198        assert_eq!(tag.db_number, 1);
199        assert_eq!(tag.byte_offset, 10);
200        assert_eq!(tag.transport, TransportSize::Word);
201    }
202
203    #[test]
204    fn parse_comma_separator_unchanged() {
205        let a = parse_tag("DB170,REAL262").unwrap();
206        let b = parse_tag("DB170.REAL262").unwrap();
207        assert_eq!(a.db_number, b.db_number);
208        assert_eq!(a.byte_offset, b.byte_offset);
209        assert_eq!(a.transport, b.transport);
210    }
211
212    #[test]
213    fn parse_bit_access_dot_not_confused() {
214        // DB70,332.0 — the comma is already there, dot is bit separator
215        let tag = parse_tag("DB70,332.0").unwrap();
216        assert_eq!(tag.transport, TransportSize::Bit);
217        assert_eq!(tag.byte_offset, 332);
218        assert_eq!(tag.bit_offset, 0);
219    }
220}