Skip to main content

bacnet_ops/format/
logged.rs

1//! A log's values named by the property it samples (#2091).
2//!
3//! A Trend Log record carries a bare value: an enumeration is a number and
4//! a bit string its bits, with nothing to say which property they came
5//! from. The log's Log_DeviceObjectProperty does say: one reference for a
6//! Trend Log (Clause 12.25), one per member for a Trend Log Multiple
7//! (Clause 12.30), each in the order of the record's values. Read once
8//! per read of the log, it lets the formatter name each column's
9//! values as it would name the monitored property itself: `offnormal`
10//! rather than `enum 2`, `in-alarm` rather than `bits(1000)`, and a binary
11//! object's state as `active`.
12//!
13//! A column that samples a binary or multi-state object's state (its
14//! Present_Value, Relinquish_Default, a Priority_Array slot and the like)
15//! is named further by that object's own state texts, read once per read
16//! of the log after the reference ([`LogColumns::read_state_texts`],
17//! #2122): `active (Running)` and `2 (Low)`, as the TUI names an object's
18//! state. At most [`MAX_STATE_TEXT_OBJECTS`] objects are read, and only
19//! objects on the log's own device.
20//!
21//! Naming never fails a read. A reference that can't be read, doesn't
22//! decode or names nothing leaves its column as the log gives it, and so
23//! does a value whose datatype the property doesn't name (a REAL logged
24//! where an enumeration was expected prints as the REAL).
25
26use std::future::Future;
27use std::ops::ControlFlow;
28
29use bacnet_encoding::constructed::decode_device_object_property_reference;
30use bacnet_types::enums::{ObjectType, PropertyIdentifier};
31use bacnet_types::primitives::{ObjectIdentifier, PropertyValue};
32
33use super::{Datum, StateTexts, ValueFormat, holds_state, is_binary, is_multistate};
34
35/// The property a log column samples, from its Log_DeviceObjectProperty
36/// reference.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub struct LoggedProperty {
39    /// The monitored object. Its type names a binary or multi-state
40    /// object's states.
41    pub object: ObjectIdentifier,
42    /// The monitored property.
43    pub property: PropertyIdentifier,
44    /// The array index the reference names, if any.
45    pub index: Option<u32>,
46    /// The device instance the reference names, if it names one; `None`
47    /// means the log's own device.
48    pub device: Option<u32>,
49}
50
51impl LoggedProperty {
52    /// Whether this column samples a binary or multi-state object's state,
53    /// which that object's state texts name.
54    pub fn holds_states(&self) -> bool {
55        let object_type = self.object.object_type();
56        (is_binary(object_type) || is_multistate(object_type)) && holds_state(self.property)
57    }
58
59    /// Whether the monitored object is on the log's device, `log_device`
60    /// when it is known: always for a reference that names no device.
61    fn on(&self, log_device: Option<u32>) -> bool {
62        self.device.is_none_or(|device| log_device == Some(device))
63    }
64}
65
66/// The most objects one read of a log reads state texts for: a Trend Log
67/// needs one, and a Trend Log Multiple with more state members than this
68/// names the rest by number, as before (#2122).
69pub const MAX_STATE_TEXT_OBJECTS: usize = 8;
70
71/// No state texts, for a column without them.
72static NO_TEXTS: StateTexts = StateTexts {
73    active: None,
74    inactive: None,
75    states: Vec::new(),
76};
77
78/// What each column of a log samples: one column for a Trend Log, one per
79/// member for a Trend Log Multiple, `None` where the reference is empty;
80/// and, once read, the state texts of the objects whose states columns
81/// sample. The default names no column.
82#[derive(Debug, Clone, Default, PartialEq, Eq)]
83pub struct LogColumns {
84    columns: Vec<Option<LoggedProperty>>,
85    /// The state texts of each column as the device sent them, by column;
86    /// shorter when the last columns have none.
87    raw: Vec<StateTexts>,
88    /// The same, cleaned for display ([`clean_name`](super::clean_name)).
89    texts: Vec<StateTexts>,
90}
91
92impl LogColumns {
93    /// Whether a log of `object_type` has a Log_DeviceObjectProperty to
94    /// name its values by: a Trend Log or a Trend Log Multiple.
95    pub fn applies(object_type: ObjectType) -> bool {
96        object_type == ObjectType::TREND_LOG || object_type == ObjectType::TREND_LOG_MULTIPLE
97    }
98
99    /// The columns a Log_DeviceObjectProperty value names, read whole:
100    /// its references back to back. Octets that don't decode to the end
101    /// name no column, so the values print as the log gives them.
102    pub fn decode(data: &[u8]) -> Self {
103        let mut columns = Vec::new();
104        let mut offset = 0;
105        while offset < data.len() {
106            let Ok((reference, next)) = decode_device_object_property_reference(data, offset)
107            else {
108                return Self::default();
109            };
110            columns.push((!reference.is_unset()).then(|| {
111                LoggedProperty {
112                    object: reference.object_identifier,
113                    property: PropertyIdentifier::from_raw(reference.property_identifier),
114                    index: reference.property_array_index,
115                    // The wildcard device instance stands for the log's own.
116                    device: reference
117                        .device_identifier
118                        .map(|device| device.instance_number())
119                        .filter(|device| *device != ObjectIdentifier::WILDCARD_INSTANCE),
120                }
121            }));
122            offset = next;
123        }
124        columns.into_iter().collect()
125    }
126
127    /// The property column `column` (from 0) samples, if named.
128    pub fn column(&self, column: usize) -> Option<LoggedProperty> {
129        self.columns.get(column).copied().flatten()
130    }
131
132    /// Whether no column is named.
133    pub fn is_empty(&self) -> bool {
134        self.columns.iter().all(Option::is_none)
135    }
136
137    /// The state texts of the object column `column` samples, cleaned for
138    /// display ([`StateTexts::cleaned`]); none unless they were read. A
139    /// formatter made with them names the column's states
140    /// ([`ValueFormat::new`]). Device-supplied text: a front end shows it
141    /// as data.
142    pub fn state_texts(&self, column: usize) -> &StateTexts {
143        self.texts.get(column).unwrap_or(&NO_TEXTS)
144    }
145
146    /// [`Self::state_texts`] as the device sent them, uncleaned and uncut,
147    /// for a front end that cleans device text its own way (the MCP
148    /// server's envelopes, which also say when they cut it).
149    pub fn raw_state_texts(&self, column: usize) -> &StateTexts {
150        self.raw.get(column).unwrap_or(&NO_TEXTS)
151    }
152
153    /// The objects whose state texts would name a column, in column order,
154    /// each once, on the log's device only, at most
155    /// [`MAX_STATE_TEXT_OBJECTS`].
156    fn state_objects(&self, log_device: Option<u32>) -> Vec<ObjectIdentifier> {
157        let mut objects = Vec::new();
158        for column in self.columns.iter().flatten() {
159            if column.holds_states() && column.on(log_device) && !objects.contains(&column.object) {
160                if objects.len() == MAX_STATE_TEXT_OBJECTS {
161                    break;
162                }
163                objects.push(column.object);
164            }
165        }
166        objects
167    }
168
169    /// Give every column that samples `object`'s states on the log's
170    /// device these texts, cleaned for display.
171    fn set_state_texts(
172        &mut self,
173        log_device: Option<u32>,
174        object: ObjectIdentifier,
175        texts: &StateTexts,
176    ) {
177        let cleaned = texts.cleaned();
178        for (index, column) in self.columns.iter().enumerate() {
179            if column.is_some_and(|column| {
180                column.object == object && column.holds_states() && column.on(log_device)
181            }) {
182                if self.texts.len() <= index {
183                    self.texts.resize(index + 1, StateTexts::default());
184                    self.raw.resize(index + 1, StateTexts::default());
185                }
186                self.texts[index] = cleaned.clone();
187                self.raw[index] = texts.clone();
188            }
189        }
190    }
191
192    /// Read the state texts of the objects whose states the columns
193    /// sample, each once, through `read`, and keep them to name those
194    /// columns. `log_device` is the log's device instance, when known: an
195    /// object on another device is not read. `read` gives an object's
196    /// texts (empty ones for a refused read, which leaves its columns as
197    /// they were) or breaks off the reading (a device gone silent, a spent
198    /// budget). Nothing is read when no column samples a state.
199    pub async fn read_state_texts<F, Fut>(&mut self, log_device: Option<u32>, mut read: F)
200    where
201        F: FnMut(ObjectIdentifier) -> Fut,
202        Fut: Future<Output = ControlFlow<(), StateTexts>>,
203    {
204        for object in self.state_objects(log_device) {
205            match read(object).await {
206                ControlFlow::Continue(texts) => self.set_state_texts(log_device, object, &texts),
207                ControlFlow::Break(()) => break,
208            }
209        }
210    }
211}
212
213impl FromIterator<Option<LoggedProperty>> for LogColumns {
214    /// The columns in order, `None` for one nothing names.
215    fn from_iter<I: IntoIterator<Item = Option<LoggedProperty>>>(columns: I) -> Self {
216        Self {
217            columns: columns.into_iter().collect(),
218            raw: Vec::new(),
219            texts: Vec::new(),
220        }
221    }
222}
223
224/// The property a log's own values belong to when no column is named: one
225/// with no enumeration, named bits or constructed element.
226const UNNAMED: PropertyIdentifier = PropertyIdentifier::LOG_BUFFER;
227
228impl ValueFormat<'_> {
229    /// This formatter for the object `column` monitors, and the property to
230    /// name its values by.
231    fn for_column(&self, column: Option<LoggedProperty>) -> (Self, PropertyIdentifier) {
232        match column {
233            Some(column) => (
234                Self {
235                    object_type: column.object.object_type(),
236                    ..*self
237                },
238                column.property,
239            ),
240            None => (*self, UNNAMED),
241        }
242    }
243
244    /// One logged value as text, named for the property `column` samples,
245    /// and a state by this formatter's state texts: make it with the
246    /// column's ([`LogColumns::state_texts`]).
247    /// An enumeration nothing names shows as `enum 3`, so it isn't taken
248    /// for a count; a bit string nothing names shows as its bits.
249    ///
250    /// A primitive value is named by its property alone, whether the log
251    /// samples the whole property or one element of it (a Priority_Array
252    /// slot names its state as the property does), so `column.index` only
253    /// matters to the octet forms, which decode one element of a collection.
254    pub fn logged(&self, column: Option<LoggedProperty>, value: &PropertyValue) -> String {
255        let (format, property) = self.for_column(column);
256        let text = format.element(property, value);
257        match value {
258            PropertyValue::Enumerated(raw) if text == raw.to_string() => format!("enum {raw}"),
259            _ => text,
260        }
261    }
262
263    /// [`Self::logged`] as typed data: an enumeration with its name and a
264    /// bit string with its set bits' names, when the property names them.
265    pub fn logged_data(&self, column: Option<LoggedProperty>, value: &PropertyValue) -> Datum {
266        let (format, property) = self.for_column(column);
267        format.element_data(property, value)
268    }
269
270    /// A logged value of another datatype, as its tagged octets, as text:
271    /// decoded as a read of the property `column` samples would be.
272    pub fn logged_octets(&self, column: Option<LoggedProperty>, octets: &[u8]) -> String {
273        let (format, property) = self.for_column(column);
274        format.value_at(property, column.and_then(|column| column.index), octets)
275    }
276
277    /// [`Self::logged_octets`] as typed data.
278    pub fn logged_octets_data(&self, column: Option<LoggedProperty>, octets: &[u8]) -> Datum {
279        let (format, property) = self.for_column(column);
280        format.data_at(property, column.and_then(|column| column.index), octets)
281    }
282}
283
284#[cfg(test)]
285#[path = "logged_tests.rs"]
286mod tests;