Skip to main content

bacnet_ops/parse/
mod.rs

1//! Object/property shorthand parsing for integrators.
2//!
3//! Handles full names (kebab-case), common abbreviations, and numeric values
4//! for BACnet object types, property identifiers, and write values.
5//! A write value typed for a known property ([`parse_value_for`]) also
6//! takes its enumeration and bit names, and dates, times and timestamps
7//! in the forms the formatter prints, so what a read shows can be typed
8//! back.
9
10mod datetime;
11mod json;
12mod numeric;
13mod typed;
14
15pub use datetime::{parse_date, parse_date_time, parse_time, parse_timestamp};
16pub use typed::{parse_value_for, parse_value_for_with_priority};
17
18use bacnet_types::enums::{ObjectType, PropertyIdentifier};
19use bacnet_types::primitives::{ObjectIdentifier, PropertyValue};
20
21#[cfg(test)]
22mod datetime_tests;
23#[cfg(test)]
24mod json_tests;
25#[cfg(test)]
26mod typed_more_tests;
27#[cfg(test)]
28mod typed_tests;
29
30/// Abbreviation mappings for object types: (abbreviation, raw_value).
31///
32/// Raw values per ASHRAE 135-2020 Clause 21:
33/// 0=analog-input, 1=analog-output, 2=analog-value, 3=binary-input, 4=binary-output,
34/// 5=binary-value, 6=calendar, 7=command, 8=device, 10=file, 12=loop,
35/// 13=multi-state-input, 14=multi-state-output, 15=notification-class, 16=program,
36/// 17=schedule, 19=multi-state-value, 20=trend-log, 23=accumulator, 24=pulse-converter.
37const OBJ_ABBREVS: &[(&str, u32)] = &[
38    ("ai", 0),   // analog-input
39    ("ao", 1),   // analog-output
40    ("av", 2),   // analog-value
41    ("bi", 3),   // binary-input
42    ("bo", 4),   // binary-output
43    ("bv", 5),   // binary-value
44    ("dev", 8),  // device
45    ("msi", 13), // multi-state-input
46    ("mso", 14), // multi-state-output
47    ("msv", 19), // multi-state-value
48    ("lp", 21),  // life-safety-point
49    ("lsp", 21), // life-safety-point
50    ("sc", 17),  // schedule
51    ("cal", 6),  // calendar
52    ("nc", 15),  // notification-class
53    ("trn", 20), // trend-log
54    ("acc", 23), // accumulator
55    ("pi", 24),  // pulse-converter
56    ("lo", 12),  // loop
57    ("prg", 16), // program
58    ("cmd", 7),  // command
59];
60
61/// Abbreviation mappings for property identifiers: (abbreviation, raw_value).
62const PROP_ABBREVS: &[(&str, u32)] = &[
63    ("pv", 85),   // present-value
64    ("on", 77),   // object-name
65    ("ot", 79),   // object-type
66    ("desc", 28), // description
67    ("sf", 111),  // status-flags
68    ("es", 36),   // event-state
69    ("oos", 81),  // out-of-service
70    ("pa", 87),   // priority-array
71    ("rd", 104),  // relinquish-default
72    ("ol", 76),   // object-list
73    ("all", 8),   // ALL
74];
75
76/// Parse an object type from a string.
77///
78/// Accepts:
79/// - Full names case-insensitive with hyphens: `analog-input`, `ANALOG_INPUT`
80/// - Common abbreviations: `ai`, `ao`, `av`, `bi`, `bo`, `bv`, `dev`, etc.
81/// - Numeric values: `0`, `1`, etc.
82pub fn parse_object_type(s: &str) -> Result<ObjectType, String> {
83    let s = s.trim();
84
85    // Try numeric first.
86    if let Ok(n) = s.parse::<u32>() {
87        return Ok(ObjectType::from_raw(n));
88    }
89
90    // Try abbreviations (case-insensitive).
91    let lower = s.to_ascii_lowercase();
92    for &(abbr, raw) in OBJ_ABBREVS {
93        if lower == abbr {
94            return Ok(ObjectType::from_raw(raw));
95        }
96    }
97
98    // Try matching against ALL_NAMED (convert hyphens to underscores, case-insensitive).
99    let normalized = lower.replace('-', "_");
100    for &(name, val) in ObjectType::ALL_NAMED {
101        if name.eq_ignore_ascii_case(&normalized) {
102            return Ok(val);
103        }
104    }
105
106    Err(format!("unknown object type: '{s}'"))
107}
108
109/// Parse an object specifier like `analog-input:1` or `ai:1` into (ObjectType, instance).
110pub fn parse_object_specifier(s: &str) -> Result<(ObjectType, u32), String> {
111    let (type_str, inst_str) = s
112        .rsplit_once(':')
113        .ok_or_else(|| format!("expected 'type:instance' format, got '{s}'"))?;
114
115    let obj_type = parse_object_type(type_str)?;
116    let instance = inst_str
117        .parse::<u32>()
118        .map_err(|_| format!("invalid instance number: '{inst_str}'"))?;
119
120    Ok((obj_type, instance))
121}
122
123/// Parse a property identifier from a string.
124///
125/// Accepts:
126/// - Full names case-insensitive with hyphens: `present-value`, `object-name`
127/// - Common abbreviations: `pv`, `on`, `ot`, `desc`, `sf`, `es`, `oos`, `pa`, `rd`, `ol`, `all`
128/// - Numeric values: `85`, `77`, etc.
129/// - Array index syntax: `object-list[3]` → (OBJECT_LIST, Some(3))
130///
131/// Returns (PropertyIdentifier, Option<array_index>).
132pub fn parse_property(s: &str) -> Result<(PropertyIdentifier, Option<u32>), String> {
133    let s = s.trim();
134
135    // Check for array index: property[index]
136    let (prop_str, array_index) = if let Some(bracket_pos) = s.find('[') {
137        let end = s
138            .find(']')
139            .ok_or_else(|| format!("missing closing ']' in '{s}'"))?;
140        if end <= bracket_pos {
141            return Err(format!("malformed array index in '{s}'"));
142        }
143        let idx_str = &s[bracket_pos + 1..end];
144        let idx = idx_str
145            .parse::<u32>()
146            .map_err(|_| format!("invalid array index: '{idx_str}'"))?;
147        (&s[..bracket_pos], Some(idx))
148    } else {
149        (s, None)
150    };
151
152    let prop = parse_property_name(prop_str)?;
153    Ok((prop, array_index))
154}
155
156/// Parse just the property name (without array index).
157fn parse_property_name(s: &str) -> Result<PropertyIdentifier, String> {
158    // Try numeric first.
159    if let Ok(n) = s.parse::<u32>() {
160        return Ok(PropertyIdentifier::from_raw(n));
161    }
162
163    // Try abbreviations (case-insensitive).
164    let lower = s.to_ascii_lowercase();
165    for &(abbr, raw) in PROP_ABBREVS {
166        if lower == abbr {
167            return Ok(PropertyIdentifier::from_raw(raw));
168        }
169    }
170
171    // Try matching against ALL_NAMED (convert hyphens to underscores, case-insensitive).
172    let normalized = lower.replace('-', "_");
173    for &(name, val) in PropertyIdentifier::ALL_NAMED {
174        if name.eq_ignore_ascii_case(&normalized) {
175            return Ok(val);
176        }
177    }
178
179    Err(format!("unknown property: '{s}'"))
180}
181
182/// Parse a write value from a string, with no property to say its
183/// datatype. [`parse_value_for`] reads names and named bits for a known
184/// property.
185///
186/// Accepts:
187/// - `null` → PropertyValue::Null
188/// - `true`/`false` → PropertyValue::Boolean
189/// - `active` → PropertyValue::Enumerated(1), `inactive` → PropertyValue::Enumerated(0)
190/// - Integer without decimal: `42` → PropertyValue::Unsigned, negative → PropertyValue::Signed
191/// - Float with decimal: `72.5` → PropertyValue::Real
192/// - Quoted string: `"hello"` → PropertyValue::CharacterString
193/// - `enumerated:3` → PropertyValue::Enumerated(3)
194/// - `bits(0101)` → PropertyValue::BitString, first bit first
195/// - dates, times and date-times as the formatter prints them, bare or
196///   after `date:`/`time:`: `2026-10-19`, `2026-10-19 Mon`, `*-*-* Fri`,
197///   `07:00`, `14:30:05.25`, `any date`, `any time`; a date and a time
198///   (`2026-10-19 07:00`, or `unspecified`) is a BACnetDateTime, a Date
199///   then a Time
200/// - `object:ai:1` → PropertyValue::ObjectIdentifier
201pub fn parse_value(s: &str) -> Result<PropertyValue, String> {
202    let s = s.trim();
203
204    // Null.
205    if s.eq_ignore_ascii_case("null") {
206        return Ok(PropertyValue::Null);
207    }
208
209    // Boolean.
210    if s.eq_ignore_ascii_case("true") {
211        return Ok(PropertyValue::Boolean(true));
212    }
213    if s.eq_ignore_ascii_case("false") {
214        return Ok(PropertyValue::Boolean(false));
215    }
216
217    // Active/Inactive as enumerated.
218    if s.eq_ignore_ascii_case("active") {
219        return Ok(PropertyValue::Enumerated(1));
220    }
221    if s.eq_ignore_ascii_case("inactive") {
222        return Ok(PropertyValue::Enumerated(0));
223    }
224
225    // Date prefix: date:YYYY-MM-DD, in any form the formatter prints.
226    if let Some(rest) = s.strip_prefix("date:") {
227        return parse_date(rest).map(PropertyValue::Date);
228    }
229
230    // Time prefix: time:HH:MM[:SS[.hh]]
231    if let Some(rest) = s.strip_prefix("time:") {
232        return parse_time(rest).map(PropertyValue::Time);
233    }
234
235    // Bare dates, times and date-times, as the formatter prints them.
236    if s.eq_ignore_ascii_case("any time") || datetime::looks_like_time(s) {
237        return parse_time(s).map(PropertyValue::Time);
238    }
239    let lower = s.to_ascii_lowercase();
240    if datetime::looks_like_date(s) || lower.starts_with("any date") || lower == "unspecified" {
241        return match parse_date_time(s)? {
242            (date, None) => Ok(PropertyValue::Date(date)),
243            (date, Some(time)) => Ok(PropertyValue::List(vec![
244                PropertyValue::Date(date),
245                PropertyValue::Time(time),
246            ])),
247        };
248    }
249
250    // A bit string as its bits, as the formatter prints one without names.
251    if let Some(bits) = s.strip_prefix("bits(").and_then(|b| b.strip_suffix(')')) {
252        if !bits.bytes().all(|b| b == b'0' || b == b'1') {
253            return Err(format!(
254                "invalid bits '{bits}': 0/1 digits, first bit first"
255            ));
256        }
257        let set: Vec<bool> = bits.bytes().map(|b| b == b'1').collect();
258        return Ok(typed::bit_string(&set));
259    }
260
261    // ObjectIdentifier prefix: object:type:instance
262    if let Some(rest) = s.strip_prefix("object:") {
263        let (obj_type, instance) = parse_object_specifier(rest)?;
264        let oid = ObjectIdentifier::new(obj_type, instance)
265            .map_err(|e| format!("invalid object identifier: {e}"))?;
266        return Ok(PropertyValue::ObjectIdentifier(oid));
267    }
268
269    // Enumerated prefix.
270    if let Some(rest) = s.strip_prefix("enumerated:") {
271        let val = rest
272            .parse::<u32>()
273            .map_err(|_| format!("invalid enumerated value: '{rest}'"))?;
274        return Ok(PropertyValue::Enumerated(val));
275    }
276
277    // Quoted string.
278    if s.starts_with('"') && s.ends_with('"') && s.len() >= 2 {
279        let inner = &s[1..s.len() - 1];
280        return Ok(PropertyValue::CharacterString(inner.to_string()));
281    }
282
283    // Numeric — check for decimal point to distinguish integer vs float.
284    if s.contains('.') {
285        let val = s
286            .parse::<f32>()
287            .map_err(|_| format!("invalid float value: '{s}'"))?;
288        return numeric::finite(PropertyValue::Real(val)).map_err(|e| format!("'{s}': {e}"));
289    }
290
291    // Scientific notation (e.g., 1e10, -2.5e3).
292    if (s.contains('e') || s.contains('E'))
293        && !s.eq_ignore_ascii_case("enumerated")
294        && !s.starts_with("enumerated:")
295        && let Ok(val) = s.parse::<f32>()
296    {
297        return numeric::finite(PropertyValue::Real(val)).map_err(|e| format!("'{s}': {e}"));
298    }
299
300    // Try integer — negative is Signed, positive is Unsigned.
301    if s.starts_with('-') {
302        let val = s
303            .parse::<i32>()
304            .map_err(|_| format!("invalid integer value: '{s}'"))?;
305        return Ok(PropertyValue::Signed(val));
306    }
307
308    if let Ok(val) = s.parse::<u64>() {
309        return Ok(PropertyValue::Unsigned(val));
310    }
311
312    Err(format!("cannot parse value: '{s}'"))
313}
314
315/// A value's text and its optional `@priority` suffix, the `@` after a
316/// quoted string's closing quote when the value is one.
317fn split_priority(s: &str) -> Result<(&str, Option<u8>), String> {
318    let s = s.trim();
319
320    // Find the last '@' that separates value from priority.
321    // Be careful not to split inside a quoted string.
322    let at_pos = if let Some(stripped) = s.strip_prefix('"') {
323        // For quoted strings, look for '@' after the closing quote.
324        if let Some(end_quote) = stripped.find('"') {
325            let after_quote = end_quote + 2; // position after closing quote
326            s[after_quote..].find('@').map(|p| p + after_quote)
327        } else {
328            None
329        }
330    } else {
331        s.rfind('@')
332    };
333
334    let Some(pos) = at_pos else {
335        return Ok((s, None));
336    };
337    let priority_str = &s[pos + 1..];
338    let priority = priority_str
339        .parse::<u8>()
340        .map_err(|_| format!("invalid priority: '{priority_str}'"))?;
341    if !(1..=16).contains(&priority) {
342        return Err(format!("priority must be 1-16, got {priority}"));
343    }
344    Ok((&s[..pos], Some(priority)))
345}
346
347/// Parse a value with optional `@priority` suffix.
348///
349/// Examples: `72.5@8` → (Real(72.5), Some(8)), `null@16` → (Null, Some(16)), `42` → (Unsigned(42), None)
350pub fn parse_value_with_priority(s: &str) -> Result<(PropertyValue, Option<u8>), String> {
351    let (value, priority) = split_priority(s)?;
352    Ok((parse_value(value)?, priority))
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    #[test]
360    fn parse_object_type_full_name() {
361        assert_eq!(
362            parse_object_type("analog-input").unwrap().to_raw(),
363            ObjectType::ANALOG_INPUT.to_raw()
364        );
365        assert_eq!(
366            parse_object_type("ANALOG_INPUT").unwrap().to_raw(),
367            ObjectType::ANALOG_INPUT.to_raw()
368        );
369        assert_eq!(
370            parse_object_type("Binary-Value").unwrap().to_raw(),
371            ObjectType::BINARY_VALUE.to_raw()
372        );
373    }
374
375    #[test]
376    fn parse_object_type_abbreviation() {
377        assert_eq!(parse_object_type("ai").unwrap().to_raw(), 0);
378        assert_eq!(parse_object_type("bo").unwrap().to_raw(), 4);
379        assert_eq!(parse_object_type("dev").unwrap().to_raw(), 8);
380        assert_eq!(parse_object_type("msv").unwrap().to_raw(), 19);
381        assert_eq!(parse_object_type("AI").unwrap().to_raw(), 0);
382    }
383
384    #[test]
385    fn parse_object_type_numeric() {
386        assert_eq!(parse_object_type("0").unwrap().to_raw(), 0);
387        assert_eq!(parse_object_type("8").unwrap().to_raw(), 8);
388        assert_eq!(parse_object_type("42").unwrap().to_raw(), 42);
389    }
390
391    #[test]
392    fn parse_object_type_error() {
393        assert!(parse_object_type("nonexistent").is_err());
394    }
395
396    #[test]
397    fn parse_object_specifier_works() {
398        let (ot, inst) = parse_object_specifier("ai:1").unwrap();
399        assert_eq!(ot.to_raw(), 0);
400        assert_eq!(inst, 1);
401
402        let (ot, inst) = parse_object_specifier("analog-input:100").unwrap();
403        assert_eq!(ot.to_raw(), 0);
404        assert_eq!(inst, 100);
405
406        let (ot, inst) = parse_object_specifier("dev:1234").unwrap();
407        assert_eq!(ot.to_raw(), 8);
408        assert_eq!(inst, 1234);
409    }
410
411    #[test]
412    fn parse_object_specifier_errors() {
413        assert!(parse_object_specifier("ai").is_err()); // missing instance
414        assert!(parse_object_specifier("ai:abc").is_err()); // bad instance
415    }
416
417    #[test]
418    fn parse_property_full_name() {
419        let (p, idx) = parse_property("present-value").unwrap();
420        assert_eq!(p.to_raw(), 85);
421        assert_eq!(idx, None);
422    }
423
424    #[test]
425    fn parse_property_abbreviation() {
426        let (p, _) = parse_property("pv").unwrap();
427        assert_eq!(p.to_raw(), 85);
428        let (p, _) = parse_property("on").unwrap();
429        assert_eq!(p.to_raw(), 77);
430        let (p, _) = parse_property("all").unwrap();
431        assert_eq!(p.to_raw(), 8);
432    }
433
434    #[test]
435    fn parse_property_numeric() {
436        let (p, _) = parse_property("85").unwrap();
437        assert_eq!(p.to_raw(), 85);
438    }
439
440    #[test]
441    fn parse_property_array_index() {
442        let (p, idx) = parse_property("object-list[3]").unwrap();
443        assert_eq!(p.to_raw(), 76);
444        assert_eq!(idx, Some(3));
445
446        let (p, idx) = parse_property("ol[0]").unwrap();
447        assert_eq!(p.to_raw(), 76);
448        assert_eq!(idx, Some(0));
449    }
450
451    #[test]
452    fn parse_value_null() {
453        assert!(matches!(parse_value("null").unwrap(), PropertyValue::Null));
454        assert!(matches!(parse_value("NULL").unwrap(), PropertyValue::Null));
455    }
456
457    #[test]
458    fn parse_value_boolean() {
459        assert!(matches!(
460            parse_value("true").unwrap(),
461            PropertyValue::Boolean(true)
462        ));
463        assert!(matches!(
464            parse_value("false").unwrap(),
465            PropertyValue::Boolean(false)
466        ));
467    }
468
469    #[test]
470    fn parse_value_active_inactive() {
471        assert!(matches!(
472            parse_value("active").unwrap(),
473            PropertyValue::Enumerated(1)
474        ));
475        assert!(matches!(
476            parse_value("inactive").unwrap(),
477            PropertyValue::Enumerated(0)
478        ));
479    }
480
481    #[test]
482    fn parse_value_unsigned() {
483        match parse_value("42").unwrap() {
484            PropertyValue::Unsigned(v) => assert_eq!(v, 42),
485            other => panic!("expected Unsigned, got {other:?}"),
486        }
487    }
488
489    #[test]
490    fn parse_value_signed() {
491        match parse_value("-5").unwrap() {
492            PropertyValue::Signed(v) => assert_eq!(v, -5),
493            other => panic!("expected Signed, got {other:?}"),
494        }
495    }
496
497    #[test]
498    fn parse_value_real() {
499        match parse_value("72.5").unwrap() {
500            PropertyValue::Real(v) => assert!((v - 72.5).abs() < f32::EPSILON),
501            other => panic!("expected Real, got {other:?}"),
502        }
503    }
504
505    #[test]
506    fn parse_value_string() {
507        match parse_value("\"hello world\"").unwrap() {
508            PropertyValue::CharacterString(s) => assert_eq!(s, "hello world"),
509            other => panic!("expected CharacterString, got {other:?}"),
510        }
511    }
512
513    #[test]
514    fn parse_value_enumerated() {
515        match parse_value("enumerated:3").unwrap() {
516            PropertyValue::Enumerated(v) => assert_eq!(v, 3),
517            other => panic!("expected Enumerated, got {other:?}"),
518        }
519    }
520
521    #[test]
522    fn parse_value_with_priority_works() {
523        let (val, pri) = parse_value_with_priority("72.5@8").unwrap();
524        assert!(matches!(val, PropertyValue::Real(_)));
525        assert_eq!(pri, Some(8));
526
527        let (val, pri) = parse_value_with_priority("null@16").unwrap();
528        assert!(matches!(val, PropertyValue::Null));
529        assert_eq!(pri, Some(16));
530
531        let (val, pri) = parse_value_with_priority("42").unwrap();
532        assert!(matches!(val, PropertyValue::Unsigned(42)));
533        assert_eq!(pri, None);
534    }
535
536    #[test]
537    fn parse_value_with_priority_invalid() {
538        assert!(parse_value_with_priority("42@0").is_err()); // priority 0 invalid
539        assert!(parse_value_with_priority("42@17").is_err()); // priority 17 invalid
540    }
541
542    #[test]
543    fn lp_maps_to_life_safety_point() {
544        assert_eq!(parse_object_type("lp").unwrap().to_raw(), 21);
545        assert_eq!(parse_object_type("lsp").unwrap().to_raw(), 21);
546        assert_eq!(parse_object_type("cal").unwrap().to_raw(), 6);
547    }
548
549    #[test]
550    fn bracket_ordering_panic() {
551        assert!(parse_property("pv]3[").is_err());
552    }
553
554    #[test]
555    fn scientific_notation_real() {
556        match parse_value("1e10").unwrap() {
557            PropertyValue::Real(v) => assert!((v - 1e10).abs() < 1e5),
558            other => panic!("expected Real, got {other:?}"),
559        }
560        match parse_value("-2.5e3").unwrap() {
561            PropertyValue::Real(v) => assert!((v - (-2500.0)).abs() < f32::EPSILON),
562            other => panic!("expected Real, got {other:?}"),
563        }
564    }
565
566    #[test]
567    fn parse_date_value() {
568        match parse_value("date:2024-03-15").unwrap() {
569            PropertyValue::Date(d) => {
570                assert_eq!(d.year, 124); // 2024 - 1900
571                assert_eq!(d.month, 3);
572                assert_eq!(d.day, 15);
573                assert_eq!(d.day_of_week, 0xFF);
574            }
575            other => panic!("expected Date, got {other:?}"),
576        }
577    }
578
579    #[test]
580    fn parse_time_value() {
581        match parse_value("time:14:30:00").unwrap() {
582            PropertyValue::Time(t) => {
583                assert_eq!(t.hour, 14);
584                assert_eq!(t.minute, 30);
585                assert_eq!(t.second, 0);
586                assert_eq!(t.hundredths, 0);
587            }
588            other => panic!("expected Time, got {other:?}"),
589        }
590    }
591
592    #[test]
593    fn parse_object_identifier_value() {
594        match parse_value("object:ai:1").unwrap() {
595            PropertyValue::ObjectIdentifier(oid) => {
596                assert_eq!(oid.object_type().to_raw(), 0);
597                assert_eq!(oid.instance_number(), 1);
598            }
599            other => panic!("expected ObjectIdentifier, got {other:?}"),
600        }
601    }
602
603    #[test]
604    fn empty_string_with_priority() {
605        let (val, pri) = parse_value_with_priority("\"\"@8").unwrap();
606        match val {
607            PropertyValue::CharacterString(s) => assert_eq!(s, ""),
608            other => panic!("expected CharacterString, got {other:?}"),
609        }
610        assert_eq!(pri, Some(8));
611    }
612}