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    let upper = tag.to_uppercase();
17
18    // Single-part tags: T<n>, C<n>, MB<n>, MW<n>, MD<n>, MX<n>.<b>, M<n>.<b>
19    // These do not use a comma separator.
20    if upper.starts_with('T') && !upper.starts_with("TM") && !upper.contains(',') {
21        let idx_str = upper.strip_prefix('T').unwrap_or("");
22        let index: u32 = idx_str.parse().map_err(|_| Error::PlcError {
23            code: 0,
24            message: format!("invalid timer index in tag: {}", tag),
25        })?;
26        return Ok(TagAddress {
27            area: Area::Timer,
28            db_number: 0,
29            byte_offset: index,
30            bit_offset: 0,
31            transport: TransportSize::Timer,
32            element_count: 1,
33        });
34    }
35    if upper.starts_with('C') && !upper.starts_with("CT") && !upper.contains(',') {
36        let idx_str = upper.strip_prefix('C').unwrap_or("");
37        let index: u32 = idx_str.parse().map_err(|_| Error::PlcError {
38            code: 0,
39            message: format!("invalid counter index in tag: {}", tag),
40        })?;
41        return Ok(TagAddress {
42            area: Area::Counter,
43            db_number: 0,
44            byte_offset: index,
45            bit_offset: 0,
46            transport: TransportSize::Counter,
47            element_count: 1,
48        });
49    }
50    if upper.starts_with('M') && !upper.starts_with("MK") && !upper.contains(',') {
51        return parse_marker_tag(&upper, "", tag);
52    }
53
54    // Accept both "DB170,REAL262" and "DB170.REAL262" as separators.
55    // The dot separator is only valid between the area (DB\d+) and the type;
56    // normalize it to a comma so the rest of the parser is uniform.
57    let normalized: std::borrow::Cow<str> = if tag.contains(',') {
58        std::borrow::Cow::Borrowed(tag)
59    } else {
60        // Find first '.' that is preceded by a digit (end of DB number) and
61        // followed by a letter (start of type name), then replace it with ','.
62        // Find pattern: digit '.' letter  e.g. "170.REAL" → replace '.' with ','
63        let bytes = tag.as_bytes();
64        let sep = bytes.windows(3).enumerate().find_map(|(i, w)| {
65            if w[0].is_ascii_digit() && w[1] == b'.' && w[2].is_ascii_alphabetic() {
66                Some(i + 1) // position of the '.'
67            } else {
68                None
69            }
70        });
71        if let Some(pos) = sep {
72            let mut s = tag.to_string();
73            s.replace_range(pos..pos + 1, ",");
74            std::borrow::Cow::Owned(s)
75        } else {
76            std::borrow::Cow::Borrowed(tag)
77        }
78    };
79
80    let parts: Vec<&str> = normalized.splitn(2, ',').collect();
81    if parts.len() != 2 {
82        return Err(Error::PlcError {
83            code: 0,
84            message: format!("invalid tag: {}", tag),
85        });
86    }
87    let area_part = parts[0].to_uppercase();
88    let type_part = parts[1].to_uppercase();
89
90    let (area, db_number) = if let Some(rest) = area_part.strip_prefix("DB") {
91        let n: u16 = rest.parse().map_err(|_| Error::PlcError {
92            code: 0,
93            message: format!("invalid DB number in tag: {}", tag),
94        })?;
95        (Area::DataBlock, n)
96    } else {
97        return Err(Error::PlcError {
98            code: 0,
99            message: format!("unsupported area in tag: {}", tag),
100        });
101    };
102
103    // Support bit access format: DB70,332.0 (byte 332, bit 0)
104    // type_part is already uppercased from line 24
105    // For bit access, type_part starts with a digit (byte offset)
106    if type_part.starts_with(|c: char| c.is_ascii_digit()) {
107        let bits: Vec<&str> = type_part.split('.').collect();
108        if bits.len() == 2 {
109            let byte_offset: u32 = bits[0].parse().map_err(|_| Error::PlcError {
110                code: 0,
111                message: format!("invalid byte offset in tag: {}", tag),
112            })?;
113            let bit_offset: u8 = bits[1].parse().map_err(|_| Error::PlcError {
114                code: 0,
115                message: format!("invalid bit offset in tag: {}", tag),
116            })?;
117            if bit_offset > 7 {
118                return Err(Error::PlcError {
119                    code: 0,
120                    message: format!("bit offset must be 0-7: {}", tag),
121                });
122            }
123            return Ok(TagAddress {
124                area,
125                db_number,
126                byte_offset,
127                bit_offset,
128                transport: TransportSize::Bit,
129                element_count: 1,
130            });
131        }
132    }
133
134    let (transport, byte_offset) = parse_typed_offset(&type_part, tag)?;
135
136    Ok(TagAddress {
137        area,
138        db_number,
139        byte_offset,
140        bit_offset: 0,
141        transport,
142        element_count: 1,
143    })
144}
145
146/// Parse a Merker (M) tag. area_part and type_part are already uppercased.
147/// Formats:
148///   MX10.3  or  M10.3  → bit access (byte 10, bit 3)
149///   MB10              → byte at offset 10
150///   MW10              → word at offset 10
151///   MD10              → dword at offset 10
152fn parse_marker_tag(area_part: &str, _type_part: &str, tag: &str) -> Result<TagAddress> {
153    // area_part already uppercased; strip leading "M"
154    let rest = area_part.strip_prefix('M').unwrap_or("");
155
156    // MX or plain M followed by digit.digit → bit
157    let (is_bit, offset_str) = if let Some(r) = rest.strip_prefix('X') {
158        (true, r)
159    } else if rest.starts_with(|c: char| c.is_ascii_digit()) {
160        (true, rest)
161    } else {
162        (false, rest)
163    };
164
165    if is_bit {
166        // expect "BYTE.BIT" e.g. "10.3"
167        let parts: Vec<&str> = offset_str.split('.').collect();
168        if parts.len() == 2 {
169            let byte_offset: u32 = parts[0].parse().map_err(|_| Error::PlcError {
170                code: 0,
171                message: format!("invalid byte offset in marker tag: {}", tag),
172            })?;
173            let bit_offset: u8 = parts[1].parse().map_err(|_| Error::PlcError {
174                code: 0,
175                message: format!("invalid bit offset in marker tag: {}", tag),
176            })?;
177            if bit_offset > 7 {
178                return Err(Error::PlcError {
179                    code: 0,
180                    message: format!("bit offset must be 0-7: {}", tag),
181                });
182            }
183            return Ok(TagAddress {
184                area: Area::Marker,
185                db_number: 0,
186                byte_offset,
187                bit_offset,
188                transport: TransportSize::Bit,
189                element_count: 1,
190            });
191        }
192        return Err(Error::PlcError {
193            code: 0,
194            message: format!("invalid marker bit tag (expected M<byte>.<bit>): {}", tag),
195        });
196    }
197
198    // MB / MW / MD
199    let (transport, offset_str) = if let Some(r) = rest.strip_prefix('B') {
200        (TransportSize::Byte, r)
201    } else if let Some(r) = rest.strip_prefix('W') {
202        (TransportSize::Word, r)
203    } else if let Some(r) = rest.strip_prefix('D') {
204        (TransportSize::DWord, r)
205    } else {
206        return Err(Error::PlcError {
207            code: 0,
208            message: format!("unsupported marker type in tag: {} (use MB/MW/MD/MX or M<byte>.<bit>)", tag),
209        });
210    };
211
212    let byte_offset: u32 = offset_str.parse().map_err(|_| Error::PlcError {
213        code: 0,
214        message: format!("invalid offset in marker tag: {}", tag),
215    })?;
216
217    Ok(TagAddress {
218        area: Area::Marker,
219        db_number: 0,
220        byte_offset,
221        bit_offset: 0,
222        transport,
223        element_count: 1,
224    })
225}
226
227fn parse_typed_offset(type_part: &str, tag: &str) -> Result<(TransportSize, u32)> {
228    if let Some(rest) = type_part.strip_prefix("REAL") {
229        Ok((TransportSize::Real, rest.parse().unwrap_or(0)))
230    } else if let Some(rest) = type_part.strip_prefix("DWORD") {
231        Ok((TransportSize::DWord, rest.parse().unwrap_or(0)))
232    } else if let Some(rest) = type_part.strip_prefix("DINT") {
233        Ok((TransportSize::DInt, rest.parse().unwrap_or(0)))
234    } else if let Some(rest) = type_part.strip_prefix("WORD") {
235        Ok((TransportSize::Word, rest.parse().unwrap_or(0)))
236    } else if let Some(rest) = type_part.strip_prefix("INT") {
237        Ok((TransportSize::Int, rest.parse().unwrap_or(0)))
238    } else if let Some(rest) = type_part.strip_prefix("BYTE") {
239        Ok((TransportSize::Byte, rest.parse().unwrap_or(0)))
240    } else {
241        Err(Error::PlcError {
242            code: 0,
243            message: format!("unsupported type in tag: {}", tag),
244        })
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    #[test]
253    fn parse_db_real() {
254        let tag = parse_tag("DB1,REAL4").unwrap();
255        assert_eq!(tag.db_number, 1);
256        assert_eq!(tag.byte_offset, 4);
257        assert_eq!(tag.transport, TransportSize::Real);
258    }
259
260    #[test]
261    fn parse_db_word() {
262        let tag = parse_tag("DB2,WORD10").unwrap();
263        assert_eq!(tag.db_number, 2);
264        assert_eq!(tag.byte_offset, 10);
265        assert_eq!(tag.transport, TransportSize::Word);
266    }
267
268    #[test]
269    fn parse_db_dint() {
270        let tag = parse_tag("DB70,DINT0").unwrap();
271        assert_eq!(tag.db_number, 70);
272        assert_eq!(tag.byte_offset, 0);
273        assert_eq!(tag.transport, TransportSize::DInt);
274        assert_eq!(tag.bit_offset, 0);
275    }
276
277    #[test]
278    fn parse_db_bit_access() {
279        let tag = parse_tag("DB70,332.0").unwrap();
280        assert_eq!(tag.db_number, 70);
281        assert_eq!(tag.byte_offset, 332);
282        assert_eq!(tag.bit_offset, 0);
283        assert_eq!(tag.transport, TransportSize::Bit);
284    }
285
286    #[test]
287    fn parse_db_bit_access_bit7() {
288        let tag = parse_tag("DB70,332.7").unwrap();
289        assert_eq!(tag.db_number, 70);
290        assert_eq!(tag.byte_offset, 332);
291        assert_eq!(tag.bit_offset, 7);
292        assert_eq!(tag.transport, TransportSize::Bit);
293    }
294
295    #[test]
296    fn parse_db_bit_invalid_bit() {
297        let result = parse_tag("DB70,332.8");
298        assert!(result.is_err());
299    }
300
301    #[test]
302    fn parse_invalid_returns_err() {
303        assert!(parse_tag("NOTVALID").is_err());
304    }
305
306    #[test]
307    fn parse_dot_separator_real() {
308        let tag = parse_tag("DB170.REAL262").unwrap();
309        assert_eq!(tag.db_number, 170);
310        assert_eq!(tag.byte_offset, 262);
311        assert_eq!(tag.transport, TransportSize::Real);
312    }
313
314    #[test]
315    fn parse_dot_separator_word() {
316        let tag = parse_tag("DB1.WORD10").unwrap();
317        assert_eq!(tag.db_number, 1);
318        assert_eq!(tag.byte_offset, 10);
319        assert_eq!(tag.transport, TransportSize::Word);
320    }
321
322    #[test]
323    fn parse_comma_separator_unchanged() {
324        let a = parse_tag("DB170,REAL262").unwrap();
325        let b = parse_tag("DB170.REAL262").unwrap();
326        assert_eq!(a.db_number, b.db_number);
327        assert_eq!(a.byte_offset, b.byte_offset);
328        assert_eq!(a.transport, b.transport);
329    }
330
331    #[test]
332    fn parse_bit_access_dot_not_confused() {
333        // DB70,332.0 — the comma is already there, dot is bit separator
334        let tag = parse_tag("DB70,332.0").unwrap();
335        assert_eq!(tag.transport, TransportSize::Bit);
336        assert_eq!(tag.byte_offset, 332);
337        assert_eq!(tag.bit_offset, 0);
338    }
339
340    #[test]
341    fn parse_timer_single_part() {
342        let tag = parse_tag("T5").unwrap();
343        assert_eq!(tag.area, Area::Timer);
344        assert_eq!(tag.byte_offset, 5);
345        assert_eq!(tag.transport, TransportSize::Timer);
346        assert_eq!(tag.db_number, 0);
347    }
348
349    #[test]
350    fn parse_counter_single_part() {
351        let tag = parse_tag("C3").unwrap();
352        assert_eq!(tag.area, Area::Counter);
353        assert_eq!(tag.byte_offset, 3);
354        assert_eq!(tag.transport, TransportSize::Counter);
355        assert_eq!(tag.db_number, 0);
356    }
357
358    #[test]
359    fn parse_marker_byte_single_part() {
360        let tag = parse_tag("MB10").unwrap();
361        assert_eq!(tag.area, Area::Marker);
362        assert_eq!(tag.byte_offset, 10);
363        assert_eq!(tag.transport, TransportSize::Byte);
364    }
365
366    #[test]
367    fn parse_marker_word_single_part() {
368        let tag = parse_tag("MW20").unwrap();
369        assert_eq!(tag.area, Area::Marker);
370        assert_eq!(tag.byte_offset, 20);
371        assert_eq!(tag.transport, TransportSize::Word);
372    }
373
374    #[test]
375    fn parse_marker_dword_single_part() {
376        let tag = parse_tag("MD4").unwrap();
377        assert_eq!(tag.area, Area::Marker);
378        assert_eq!(tag.byte_offset, 4);
379        assert_eq!(tag.transport, TransportSize::DWord);
380    }
381
382    #[test]
383    fn parse_marker_bit_single_part() {
384        let tag = parse_tag("M10.3").unwrap();
385        assert_eq!(tag.area, Area::Marker);
386        assert_eq!(tag.byte_offset, 10);
387        assert_eq!(tag.bit_offset, 3);
388        assert_eq!(tag.transport, TransportSize::Bit);
389    }
390
391    #[test]
392    fn parse_marker_bit_mx_prefix() {
393        let tag = parse_tag("MX5.7").unwrap();
394        assert_eq!(tag.area, Area::Marker);
395        assert_eq!(tag.byte_offset, 5);
396        assert_eq!(tag.bit_offset, 7);
397        assert_eq!(tag.transport, TransportSize::Bit);
398    }
399}