Skip to main content

bacnet_ops/format/
mod.rs

1//! The one value formatter the TUI, the one-shot CLI and the MCP server
2//! share: property values as read, turned into the text the browser, the
3//! watch list, the write previews and the CLI's tables show, and into the
4//! typed [`Datum`] the CLI's JSON carries.
5//!
6//! It knows the object type, so a constructed value (timestamps, schedule
7//! days, references) is split with the element table from
8//! `bacnet_encoding::constructed::property_element` and shown decoded, and
9//! a binary or multi-state value is named by its state, with the object's
10//! own state text when that was read.
11//!
12//! Style, the same on every screen (decision D4 of #1887): enumerations,
13//! bit names and units in kebab case, objects as `analog-input 1`. Floats
14//! come in two forms (decision D2): [`Floats::Display`] for the browser and
15//! the watch list, at most six significant digits, and [`Floats::Exact`] for
16//! the write previews, the ledger, the CSV log and the one-shot CLI, which
17//! keep every digit. The compact places cut a long list short; the CLI
18//! shows it whole ([`ValueFormat::whole_lists`]).
19
20pub mod alarms;
21mod clean;
22mod commands;
23mod constructed;
24mod data;
25mod event_parameter;
26mod fields;
27pub mod log_records;
28mod logged;
29pub mod notification;
30mod primitive;
31mod recipients;
32mod tagged;
33mod units;
34mod wire;
35
36pub use clean::{MAX_NAME_CHARS, clean_name, clean_text};
37pub(crate) use constructed::timestamp_text;
38pub use data::{Datum, Fields, PLAIN_ELEMENTS};
39pub use logged::{LogColumns, LoggedProperty, MAX_STATE_TEXT_OBJECTS};
40pub use wire::format_property_value;
41
42use bacnet_encoding::constructed::{ConstructedElement, ElementShape, property_element};
43use bacnet_encoding::primitives::{decode_application_value, decode_priority_date_time};
44use bacnet_types::enums::{
45    EngineeringUnits, ErrorClass, ErrorCode, ObjectType, PropertyIdentifier,
46};
47use bacnet_types::error::{Error, RemoteError};
48use bacnet_types::primitives::{Date, PropertyValue, Time};
49
50use units::unit_symbol;
51
52/// A property as read: its raw application-tagged value, or why the device
53/// gave none.
54pub type RawRow = (PropertyIdentifier, Result<Vec<u8>, RowError>);
55
56/// Why one property of a read has no value. Structured, so a front end can
57/// report the device's error class and code as data, not only as text.
58#[derive(Debug, Clone, PartialEq, Eq)]
59#[non_exhaustive]
60pub enum RowError {
61    /// The device answered this property with an error, a reject or an
62    /// abort.
63    Remote(RemoteError),
64    /// The device's answer for this property held neither a value nor an
65    /// error.
66    NoValue,
67    /// Any other failure of this property's read, as text.
68    Other(String),
69}
70
71impl RowError {
72    /// The device's error class and code, when it answered with an error.
73    pub fn class_code(&self) -> Option<(ErrorClass, ErrorCode)> {
74        match self {
75            Self::Remote(RemoteError::Error { class, code }) => Some((*class, *code)),
76            _ => None,
77        }
78    }
79}
80
81impl From<Error> for RowError {
82    /// The device's error, reject or abort as [`RowError::Remote`], with a
83    /// structured Error PDU reduced to its class and code; anything else
84    /// as text.
85    fn from(error: Error) -> Self {
86        match error.remote() {
87            Some(remote) => Self::Remote(remote),
88            None => Self::Other(error.to_string()),
89        }
90    }
91}
92
93impl std::error::Error for RowError {}
94
95impl std::fmt::Display for RowError {
96    /// An error as `CLASS:CODE` (`PROPERTY:UNKNOWN_PROPERTY`), the form
97    /// the TUI shows; a reject or abort, or any other failure, as its own
98    /// text.
99    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100        match self {
101            Self::Remote(RemoteError::Error { class, code }) => write!(f, "{class}:{code}"),
102            Self::Remote(remote) => write!(f, "{remote}"),
103            Self::NoValue => f.write_str("no value"),
104            Self::Other(text) => f.write_str(text),
105        }
106    }
107}
108
109/// Values shown before the rest of a list is summarised.
110const MAX_LIST_SHOWN: usize = 8;
111
112/// Every application-tagged value in `data`, or `None` if any part of it
113/// doesn't decode. A context tag decodes as `ApplicationData`.
114pub fn decode_all(data: &[u8]) -> Option<Vec<PropertyValue>> {
115    let mut offset = 0;
116    let mut values = Vec::new();
117    while offset < data.len() {
118        let (value, next) = decode_application_value(data, offset).ok()?;
119        values.push(value);
120        offset = next;
121    }
122    Some(values)
123}
124
125/// How floats are written.
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub enum Floats {
128    /// At most six significant digits, trailing zeros trimmed.
129    Display,
130    /// Every digit: the shortest text that reads back as the same value.
131    Exact,
132}
133
134/// The object's own names for its states, when they were read.
135#[derive(Debug, Clone, Default, PartialEq, Eq)]
136pub struct StateTexts {
137    /// A binary object's Active_Text.
138    pub active: Option<String>,
139    /// A binary object's Inactive_Text.
140    pub inactive: Option<String>,
141    /// A multi-state object's State_Text, state 1 first.
142    pub states: Vec<String>,
143}
144
145impl StateTexts {
146    /// The properties that hold an object type's state texts, to read with
147    /// the value; none for a type without them.
148    pub fn properties(object_type: ObjectType) -> &'static [PropertyIdentifier] {
149        if is_binary(object_type) {
150            &[
151                PropertyIdentifier::ACTIVE_TEXT,
152                PropertyIdentifier::INACTIVE_TEXT,
153            ]
154        } else if is_multistate(object_type) {
155            &[PropertyIdentifier::STATE_TEXT]
156        } else {
157            &[]
158        }
159    }
160
161    /// Whether an object of `object_type`'s state texts would name the
162    /// value of `property` (at `index`, for an array): a binary or
163    /// multi-state object's state, in its Present_Value, Priority_Array
164    /// slots, Relinquish_Default and the like. An array's size, index 0,
165    /// never is. A front end reads the texts only when this holds.
166    pub fn name(object_type: ObjectType, property: PropertyIdentifier, index: Option<u32>) -> bool {
167        (is_binary(object_type) || is_multistate(object_type))
168            && holds_state(property)
169            && index != Some(0)
170    }
171
172    /// The same texts cleaned for display ([`clean_name`]): device text
173    /// with its control characters turned to spaces and invisible format
174    /// characters dropped.
175    pub fn cleaned(&self) -> Self {
176        Self {
177            active: self.active.as_deref().map(clean_name),
178            inactive: self.inactive.as_deref().map(clean_name),
179            states: self.states.iter().map(|text| clean_name(text)).collect(),
180        }
181    }
182
183    /// The texts among properties as read. A State_Text entry that isn't
184    /// a string reads as an empty text, which names nothing, so later
185    /// entries keep their state numbers.
186    pub fn from_rows(rows: &[RawRow]) -> Self {
187        let values = |property| {
188            rows.iter()
189                .find(|(p, _)| *p == property)
190                .and_then(|(_, value)| value.as_deref().ok())
191                .and_then(decode_all)
192                .unwrap_or_default()
193                .into_iter()
194                // A value that isn't a string holds its place as no text,
195                // so the states after it keep their positions.
196                .map(|value| match value {
197                    PropertyValue::CharacterString(text) => text,
198                    _ => String::new(),
199                })
200        };
201        let text = |property| values(property).next().filter(|text| !text.is_empty());
202        Self {
203            active: text(PropertyIdentifier::ACTIVE_TEXT),
204            inactive: text(PropertyIdentifier::INACTIVE_TEXT),
205            states: values(PropertyIdentifier::STATE_TEXT).collect(),
206        }
207    }
208}
209
210/// The binary object types, whose state is a BACnetBinaryPV.
211pub(crate) fn is_binary(object_type: ObjectType) -> bool {
212    [
213        ObjectType::BINARY_INPUT,
214        ObjectType::BINARY_OUTPUT,
215        ObjectType::BINARY_VALUE,
216    ]
217    .contains(&object_type)
218}
219
220/// The multi-state object types, whose state is an Unsigned from 1.
221pub(crate) fn is_multistate(object_type: ObjectType) -> bool {
222    [
223        ObjectType::MULTI_STATE_INPUT,
224        ObjectType::MULTI_STATE_OUTPUT,
225        ObjectType::MULTI_STATE_VALUE,
226    ]
227    .contains(&object_type)
228}
229
230/// The DateTime Value object types, whose commanded value is a
231/// BACnetDateTime.
232pub(crate) fn is_date_time_value(object_type: ObjectType) -> bool {
233    [
234        ObjectType::DATETIME_VALUE,
235        ObjectType::DATETIMEPATTERN_VALUE,
236    ]
237    .contains(&object_type)
238}
239
240/// The properties that hold a binary or multi-state object's state.
241pub(crate) fn holds_state(property: PropertyIdentifier) -> bool {
242    [
243        PropertyIdentifier::PRESENT_VALUE,
244        PropertyIdentifier::FEEDBACK_VALUE,
245        PropertyIdentifier::RELINQUISH_DEFAULT,
246        PropertyIdentifier::PRIORITY_ARRAY,
247        PropertyIdentifier::ALARM_VALUE,
248        PropertyIdentifier::ALARM_VALUES,
249        PropertyIdentifier::FAULT_VALUES,
250    ]
251    .contains(&property)
252}
253
254/// Formats the values of one object.
255#[derive(Debug, Clone, Copy)]
256pub struct ValueFormat<'a> {
257    object_type: ObjectType,
258    texts: &'a StateTexts,
259    floats: Floats,
260    /// Elements of a list shown before the rest is summarised.
261    max_shown: usize,
262}
263
264impl<'a> ValueFormat<'a> {
265    /// A formatter for an object of `object_type`.
266    pub fn new(object_type: ObjectType, texts: &'a StateTexts, floats: Floats) -> Self {
267        Self {
268            object_type,
269            texts,
270            floats,
271            max_shown: MAX_LIST_SHOWN,
272        }
273    }
274
275    /// The same formatter, showing every element of a list instead of the
276    /// first few and a count.
277    pub fn whole_lists(self) -> Self {
278        Self {
279            max_shown: usize::MAX,
280            ..self
281        }
282    }
283
284    /// The same settings for another object, as a value that names one
285    /// (an action's target, a group member) needs.
286    pub fn for_object<'b>(
287        &self,
288        object_type: ObjectType,
289        texts: &'b StateTexts,
290    ) -> ValueFormat<'b> {
291        ValueFormat {
292            object_type,
293            texts,
294            floats: self.floats,
295            max_shown: self.max_shown,
296        }
297    }
298
299    /// A REAL in this formatter's float style.
300    pub fn real(&self, value: f32) -> String {
301        primitive::text(
302            PropertyIdentifier::PRESENT_VALUE,
303            &PropertyValue::Real(value),
304            self.floats,
305        )
306    }
307
308    /// The text of each element of a constructed value, when the element
309    /// table names the property's element and the octets decode as those
310    /// elements to the end; `None` otherwise.
311    pub fn constructed_items(
312        &self,
313        property: PropertyIdentifier,
314        data: &[u8],
315    ) -> Option<Vec<String>> {
316        let (element, _) = property_element(self.object_type, property)?;
317        constructed::elements(self, element, data, 0)
318    }
319
320    /// A property's value as display text: decoded as the element the table
321    /// names for it when it has a renderer and the octets decode as those
322    /// elements to the end, and otherwise as application values.
323    pub fn value(&self, property: PropertyIdentifier, data: &[u8]) -> String {
324        self.value_at(property, None, data)
325    }
326
327    /// The element and shape of a read of `property` at `index`, and the
328    /// place (from 0) of its first element: an indexed read of a collection
329    /// is the one element at index less one, and index 0 (an array's size)
330    /// none.
331    fn element_at(
332        &self,
333        property: PropertyIdentifier,
334        index: Option<u32>,
335    ) -> Option<(ConstructedElement, ElementShape, usize)> {
336        let (element, shape) = property_element(self.object_type, property)?;
337        match index {
338            None => Some((element, shape, 0)),
339            Some(0) => None,
340            Some(index) => Some((
341                element,
342                ElementShape::Single,
343                usize::try_from(index - 1).ok()?,
344            )),
345        }
346    }
347
348    /// The elements of a read at `index`, when the table names the
349    /// property's element and the octets decode as it.
350    fn elements_at(
351        &self,
352        property: PropertyIdentifier,
353        index: Option<u32>,
354        data: &[u8],
355    ) -> Option<(ConstructedElement, ElementShape, Vec<String>)> {
356        let (element, shape, first) = self.element_at(property, index)?;
357        let texts = constructed::elements(self, element, data, first)?;
358        Some((element, shape, texts))
359    }
360
361    /// [`Self::value`] of a read at an array index: one element of a
362    /// collection for an index above 0, the array's size for index 0.
363    pub fn value_at(
364        &self,
365        property: PropertyIdentifier,
366        index: Option<u32>,
367        data: &[u8],
368    ) -> String {
369        if let Some((element, shape, texts)) = self.elements_at(property, index, data) {
370            return match (shape, texts.as_slice()) {
371                (ElementShape::Single, [one]) => one.clone(),
372                _ => self.list(texts, constructed::separator(element)),
373            };
374        }
375        let Some(values) = decode_all(data) else {
376            return primitive::raw(data);
377        };
378        match values.as_slice() {
379            [] => "(empty)".into(),
380            // An array's size is a count, never a state.
381            [one] if index == Some(0) => primitive::text(property, one, self.floats),
382            [one] => self.element(property, one),
383            [PropertyValue::Date(date), PropertyValue::Time(time)] => {
384                primitive::date_time(date, time)
385            }
386            [PropertyValue::Date(start), PropertyValue::Date(end)] => {
387                primitive::date_range(start, end)
388            }
389            many => self.list(
390                many.iter()
391                    .map(|value| self.element(property, value))
392                    .collect(),
393                ", ",
394            ),
395        }
396    }
397
398    /// A value with the units after it, as the compact places show it:
399    /// `72.5 °F`. Only numbers take units.
400    pub fn with_units(
401        &self,
402        property: PropertyIdentifier,
403        data: &[u8],
404        units: Option<EngineeringUnits>,
405    ) -> String {
406        let text = self.value(property, data);
407        let numeric = matches!(
408            decode_all(data).as_deref(),
409            Some([PropertyValue::Real(_)
410                | PropertyValue::Double(_)
411                | PropertyValue::Unsigned(_)
412                | PropertyValue::Signed(_)])
413        );
414        match units {
415            Some(units) if numeric && units != EngineeringUnits::NO_UNITS => {
416                format!("{text} {}", unit_symbol(units))
417            }
418            _ => text,
419        }
420    }
421
422    /// A property's value as the device card shows it: a string without
423    /// its quotes.
424    pub fn plain(&self, property: PropertyIdentifier, data: &[u8]) -> String {
425        match decode_all(data).as_deref() {
426            Some([PropertyValue::CharacterString(text)]) => text.clone(),
427            _ => self.value(property, data),
428        }
429    }
430
431    /// One application value of `property`.
432    pub fn element(&self, property: PropertyIdentifier, value: &PropertyValue) -> String {
433        if holds_state(property)
434            && let Some(state) = self.state(value)
435        {
436            return state;
437        }
438        if let Some((date, time)) = self.priority_date_time(property, value) {
439            return primitive::date_time(&date, &time);
440        }
441        primitive::text(property, value, self.floats)
442    }
443
444    /// A DateTime Value's priority slot holding a date-time: the
445    /// BACnetPriorityValue choice under context tag 1, which the generic
446    /// decoder leaves as its octets (#2098). `None` for anything else.
447    fn priority_date_time(
448        &self,
449        property: PropertyIdentifier,
450        value: &PropertyValue,
451    ) -> Option<(Date, Time)> {
452        let PropertyValue::ApplicationData(octets) = value else {
453            return None;
454        };
455        if !is_date_time_value(self.object_type) || property != PropertyIdentifier::PRIORITY_ARRAY {
456            return None;
457        }
458        match decode_priority_date_time(octets, 0) {
459            Ok((date_time, end)) if end == octets.len() => Some(date_time),
460            _ => None,
461        }
462    }
463
464    /// A binary or multi-state object's state, named.
465    fn state(&self, value: &PropertyValue) -> Option<String> {
466        let named = |name: String, text: Option<&String>| match text {
467            Some(text) if !text.is_empty() => format!("{name} ({text})"),
468            _ => name,
469        };
470        match value {
471            PropertyValue::Enumerated(raw @ (0 | 1)) if is_binary(self.object_type) => {
472                Some(if *raw == 1 {
473                    named("active".into(), self.texts.active.as_ref())
474                } else {
475                    named("inactive".into(), self.texts.inactive.as_ref())
476                })
477            }
478            PropertyValue::Unsigned(state) if is_multistate(self.object_type) => {
479                let text = usize::try_from(*state)
480                    .ok()
481                    .and_then(|state| state.checked_sub(1))
482                    .and_then(|index| self.texts.states.get(index));
483                Some(named(state.to_string(), text))
484            }
485            _ => None,
486        }
487    }
488}
489
490impl ValueFormat<'_> {
491    /// Element texts as a list, the first `max_shown` in full.
492    fn list(&self, texts: Vec<String>, separator: &str) -> String {
493        let count = texts.len();
494        let shown: Vec<String> = texts.into_iter().take(self.max_shown).collect();
495        match count.saturating_sub(self.max_shown) {
496            0 => format!("[{}]", shown.join(separator)),
497            more => format!(
498                "[{}{separator}+{more} more] ({count} items)",
499                shown.join(separator)
500            ),
501        }
502    }
503}
504
505/// Octets that don't decode, as every front end writes them: `[raw 0e 0f]`.
506pub fn raw_text(data: &[u8]) -> String {
507    primitive::raw(data)
508}
509
510/// The Object_Name among properties as read; `None` when the device
511/// answered it with an error or left it out. See [`name_text`].
512pub fn object_name(rows: &[RawRow]) -> Option<String> {
513    let (_, value) = rows
514        .iter()
515        .find(|(p, _)| *p == PropertyIdentifier::OBJECT_NAME)?;
516    name_text(value.as_deref().ok()?)
517}
518
519/// An encoded Object_Name as text (see [`clean_name`]); `None` unless it
520/// is one character string.
521pub fn name_text(data: &[u8]) -> Option<String> {
522    match decode_all(data)?.as_slice() {
523        [PropertyValue::CharacterString(text)] => Some(clean_name(text)),
524        _ => None,
525    }
526}
527
528/// The Units among properties as read.
529pub fn units(rows: &[RawRow]) -> Option<EngineeringUnits> {
530    let (_, value) = rows.iter().find(|(p, _)| *p == PropertyIdentifier::UNITS)?;
531    match decode_all(value.as_deref().ok()?)?.as_slice() {
532        [PropertyValue::Enumerated(raw)] => Some(EngineeringUnits::from_raw(*raw)),
533        _ => None,
534    }
535}
536
537#[cfg(test)]
538mod tests;
539
540#[cfg(test)]
541mod render_tests;