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;