Skip to main content

bacnet_objects/database/
trend_poll.rs

1//! Object-owned trend polling schedule. The caller owns synchronization and timers.
2
3use crate::collections::{HashMap, HashSet};
4use crate::prelude::*;
5use core::time::Duration;
6
7use bacnet_encoding::constructed::encode_log_multiple_record;
8use bacnet_encoding::primitives::encode_property_value;
9use bacnet_types::constructed::{
10    BACnetDeviceObjectPropertyReference, BACnetLogMultipleRecord, BACnetLogRecord, LogData,
11    LogDatum, LogValue,
12};
13use bacnet_types::enums::{
14    ErrorClass, ErrorCode, LoggingType, ObjectType, PropertyIdentifier as P,
15};
16use bacnet_types::error::{Error, RemoteError};
17use bacnet_types::primitives::{Date, ObjectIdentifier, PropertyValue, Time};
18use bytes::BytesMut;
19
20use super::{LocalDevice, ObjectDatabase};
21use crate::device_reference::decode_reference;
22use crate::log_buffer::ANY_VALUE_MAX_OCTETS;
23use crate::traits::{BACnetObject, MonotonicClock};
24use alignment::{Alignment, DAY};
25use cov::CovBaseline;
26
27mod alignment;
28mod cov;
29
30/// Local maximum idle/configuration reconciliation delay and failure backoff.
31/// This is a scheduling policy, not a BACnet timing guarantee.
32const RECONCILE: Duration = Duration::from_millis(100);
33
34#[derive(PartialEq)]
35struct Configuration {
36    mode: Mode,
37    /// Log_DeviceObjectProperty as read, compared whole for scheduling
38    /// ownership.
39    reference: PropertyValue,
40    /// What each acquisition reads: the one reference of a Trend Log, or every
41    /// element of a Trend Log Multiple in array order.
42    members: Vec<Member>,
43}
44
45/// When a log acquires.
46#[derive(Debug, Clone, Copy, PartialEq)]
47enum Mode {
48    /// POLLED: every `interval` hundredths, counted from each acquisition.
49    Polled { interval: u32 },
50    /// POLLED with Align_Intervals (Clauses 12.25.27 and 12.30.14): at each
51    /// local time of day `offset` hundredths past a multiple of `interval`,
52    /// which divides a day. `offset` is Interval_Offset modulo the interval
53    /// (12.25.28 and 12.30.15).
54    Aligned { interval: u32, offset: u32 },
55    /// TRIGGERED with Trigger TRUE: one acquisition now (Clauses 12.25.29
56    /// and 12.30.16).
57    Triggered,
58    /// A Trend Log's COV: on every pass, one acquisition when the reference
59    /// has changed by COV's criteria, held to Client_COV_Increment when it
60    /// isn't NULL (Clauses 12.25.11 and 12.25.26, #1480). See the `cov`
61    /// module.
62    Cov { increment: Option<f32> },
63}
64
65/// One monitored reference of a log.
66#[derive(Debug, Clone, Copy, PartialEq)]
67enum Member {
68    /// A Trend Log Multiple element naming object or device instance 4194303,
69    /// which Clause 12.30.11 treats as empty, or the unset reference of a
70    /// triggered Trend Log, which reads the same way (#1417).
71    Unspecified,
72    /// A property to read, at one element when `index` is present.
73    Reference {
74        target: ObjectIdentifier,
75        property: P,
76        index: Option<u32>,
77        /// The reference's optional Device member.
78        device: Option<ObjectIdentifier>,
79    },
80}
81
82struct Schedule {
83    configuration: Configuration,
84    last_success: Option<Duration>,
85    /// Latest failed attempt, independent of the last accepted sample.
86    retry_completed: Option<Duration>,
87    /// An aligned log's plan, kept on the Device clock.
88    alignment: Alignment,
89    /// A COV log's last logged value, which the next change is measured
90    /// from; `None` until the first is logged.
91    cov_baseline: Option<CovBaseline>,
92}
93
94impl Schedule {
95    fn new(configuration: Configuration) -> Self {
96        Self {
97            configuration,
98            last_success: None,
99            retry_completed: None,
100            alignment: Alignment::default(),
101            cov_baseline: None,
102        }
103    }
104
105    fn remaining(&self, now: Duration) -> Duration {
106        if let Some(failed) = self.retry_completed {
107            return RECONCILE.saturating_sub(now.saturating_sub(failed));
108        }
109        match (self.configuration.mode, self.last_success) {
110            (Mode::Polled { interval }, Some(success)) => {
111                hundredths(interval).saturating_sub(now.saturating_sub(success))
112            }
113            (Mode::Aligned { interval, offset }, _) => self.alignment.wait(now, interval, offset),
114            // A COV log looks again on the next pass.
115            (Mode::Cov { .. }, _) => RECONCILE,
116            _ => Duration::ZERO,
117        }
118    }
119}
120
121fn hundredths(value: u32) -> Duration {
122    Duration::from_millis(u64::from(value) * 10)
123}
124
125#[derive(Default)]
126pub(super) struct TrendPollSchedule(HashMap<ObjectIdentifier, Schedule>);
127
128impl TrendPollSchedule {
129    pub(super) fn retire(&mut self, oid: &ObjectIdentifier) {
130        self.0.remove(oid);
131    }
132
133    pub(super) fn clear(&mut self) {
134        self.0.clear();
135    }
136}
137
138impl ObjectDatabase {
139    /// Poll every due local Trend Log or Trend Log Multiple synchronously and
140    /// return the next wait.
141    ///
142    /// Each acquisition reads every reference of the log: a Trend Log's one,
143    /// or each Log_DeviceObjectProperty element of a Trend Log Multiple, whose
144    /// record then carries one value per element in array order (#1203).
145    /// Only this database is read. A reference whose Device member names
146    /// another device (see [`ObjectDatabase::local_device`]) is never read
147    /// here, even when a same-numbered local object exists: its value is a
148    /// failure, PROPERTY / OPTIONAL_FUNCTIONALITY_NOT_SUPPORTED. A Trend Log
149    /// Multiple element naming instance 4194303 is empty and fails with
150    /// PROPERTY / NO_PROPERTY_SPECIFIED. A reference with an array index reads
151    /// that element (#1205). A local read that fails yields the error a
152    /// ReadProperty of it would report: OBJECT / UNKNOWN_OBJECT for a missing
153    /// object, PROPERTY / PROPERTY_IS_NOT_AN_ARRAY for an index on a property
154    /// that isn't an array, or the read's own error, such as INVALID_ARRAY_INDEX
155    /// for an index past the end (#1183).
156    ///
157    /// Only POLLED logs with a nonzero Log_Interval and at least one reference
158    /// are polled. A TRIGGERED log whose Trigger reads TRUE makes one
159    /// acquisition, and the object clears Trigger once it accepts the record
160    /// (Clauses 12.25.29 and 12.30.16). A Trend Log Multiple without members
161    /// records no values for it, and a Trend Log without a reference records
162    /// PROPERTY / NO_PROPERTY_SPECIFIED, so Trigger never stays TRUE. With
163    /// Align_Intervals TRUE and a Log_Interval that divides a day, a POLLED
164    /// log acquires when the Device clock's time of day is Interval_Offset
165    /// (modulo the interval) past a multiple of the interval (Clauses
166    /// 12.25.27-28 and 12.30.14-15); the first acquisition waits for such a
167    /// boundary. Such a log is judged against the Device clock on every pass,
168    /// so a clock change moves its plan with it (see the `alignment` module).
169    /// A Trend Log whose Logging_Type is COV is looked at on every pass and
170    /// logs its reference, with the monitored object's Status_Flags, when it
171    /// changed by COV's criteria (#1480; see the `cov` module).
172    ///
173    /// Every pass also lets each Trend Log, Trend Log Multiple and Event Log
174    /// look at its Start_Time / Stop_Time window, with or without a
175    /// monotonic clock, so one that opens or closes is recorded within a pass
176    /// even when no record arrives. An Event Log takes its records from the
177    /// application and the device's notifications, never from this poller,
178    /// but this pass is the one regular visit every log gets.
179    ///
180    /// The caller must hold exclusive database access for this whole call. The
181    /// bound monotonic clock drives scheduling; the shared Device clock
182    /// provides each actual acquisition timestamp. Without either clock an
183    /// attempt cannot succeed. A successful call to an object's insertion hook,
184    /// including its accepted disabled/count-only outcomes, starts the
185    /// configured interval.
186    ///
187    /// Log_Interval is in hundredths. Deadlines follow actual completion, without
188    /// catch-up bursts. Invalid timestamps and insertion errors preserve the last
189    /// success and retry after 100 ms. The returned wait is at most 100 ms so a
190    /// timer-driven caller also reconciles configuration changes and idle logs.
191    /// Slow synchronous work can make another object due; a minimum 1 ms yield
192    /// avoids a zero-delay loop. These bounds are local policy, not real-time
193    /// guarantees. Clock, object reads and insertion hooks must remain bounded.
194    pub fn poll_trend_logs(&mut self) -> Duration {
195        let mut logs = self.find_by_type(ObjectType::TREND_LOG);
196        logs.extend(self.find_by_type(ObjectType::TREND_LOG_MULTIPLE));
197        let trend_logs = logs.len();
198        logs.extend(self.find_by_type(ObjectType::EVENT_LOG));
199        // Every log looks at its window, Event Logs included, whether or not
200        // a monotonic clock drives acquisition.
201        for oid in &logs {
202            if let Some(log) = self.get_mut(oid) {
203                log.refresh_log_window_internal();
204            }
205        }
206        logs.truncate(trend_logs);
207        let Some(monotonic) = self.monotonic_clock.clone() else {
208            return RECONCILE;
209        };
210        let mut eligible = HashSet::new();
211        let local = self.local_device();
212        for oid in logs {
213            // Exclusive access prevents structural change after selection.
214            let Some(configuration) = self.get(&oid).and_then(configuration) else {
215                continue;
216            };
217            eligible.insert(oid);
218            match self.trend_poll.0.get_mut(&oid) {
219                Some(entry) if entry.configuration == configuration => {}
220                // A COV log whose Client_COV_Increment alone changed keeps
221                // its baseline: the next look measures from the last logged
222                // value with the new increment, so nothing is logged twice.
223                Some(entry) if only_increment_changed(&entry.configuration, &configuration) => {
224                    entry.configuration = configuration;
225                }
226                _ => {
227                    self.trend_poll.0.insert(oid, Schedule::new(configuration));
228                }
229            }
230            self.poll_one(oid, local, &*monotonic);
231        }
232        self.trend_poll.0.retain(|oid, _| eligible.contains(oid));
233        let now = monotonic();
234        let remaining = self
235            .trend_poll
236            .0
237            .values()
238            .map(|entry| entry.remaining(now))
239            .min()
240            .unwrap_or(RECONCILE)
241            .min(RECONCILE);
242        if remaining.is_zero() {
243            Duration::from_millis(1)
244        } else {
245            remaining
246        }
247    }
248
249    /// Acquire for `oid`'s schedule entry when it is due.
250    fn poll_one(&mut self, oid: ObjectIdentifier, local: LocalDevice, monotonic: &MonotonicClock) {
251        let now = monotonic();
252        let entry = &self.trend_poll.0[&oid];
253        let mode = entry.configuration.mode;
254        if let Mode::Cov { increment } = mode {
255            self.poll_cov(oid, local, increment, monotonic);
256            return;
257        }
258        // An aligned log looks at the Device clock on every pass, so its plan
259        // follows a clock change; the others wait for their deadline. Both
260        // wait out the retry delay after a failed attempt.
261        let waiting = match mode {
262            Mode::Aligned { .. } => entry
263                .retry_completed
264                .is_some_and(|failed| now.saturating_sub(failed) < RECONCILE),
265            _ => !entry.remaining(now).is_zero(),
266        };
267        if waiting {
268            return;
269        }
270        let frame = self
271            .clock_frame()
272            .filter(|frame| frame.is_valid_actual_datetime());
273        let entry = self.trend_poll.0.get_mut(&oid).unwrap();
274        let mut boundary = None;
275        if let Mode::Aligned { interval, offset } = mode {
276            // Without a clock there is no boundary to find; look again later.
277            let Some(frame) = frame else {
278                entry.retry_completed = Some(now);
279                return;
280            };
281            entry.retry_completed = None;
282            boundary = entry.alignment.due(now, &frame, interval, offset);
283            if boundary.is_none() {
284                return;
285            }
286        }
287        let accepted = frame.is_some_and(|frame| {
288            let values: Vec<LogValue> = self.trend_poll.0[&oid]
289                .configuration
290                .members
291                .iter()
292                .map(|member| self.acquire(local, member))
293                .collect();
294            let object = self.get_mut(&oid).unwrap();
295            let inserted = if oid.object_type() == ObjectType::TREND_LOG_MULTIPLE {
296                object.add_trend_multiple_record(BACnetLogMultipleRecord {
297                    date: frame.local_date,
298                    time: frame.local_time,
299                    log_data: LogData::Values(values),
300                })
301            } else {
302                object.add_trend_record(BACnetLogRecord {
303                    date: frame.local_date,
304                    time: frame.local_time,
305                    // A Trend Log has exactly one reference.
306                    log_datum: values
307                        .into_iter()
308                        .next()
309                        .map_or(LogDatum::NullValue, LogDatum::from),
310                    status_flags: None,
311                })
312            };
313            inserted
314                .inspect_err(|error| {
315                    log_warn!(object = %oid, %error, "trend-log record insertion failed");
316                })
317                .is_ok()
318        });
319        let completed = monotonic();
320        if accepted && mode == Mode::Triggered {
321            // Served: the next Trigger starts a fresh entry.
322            self.trend_poll.0.remove(&oid);
323            return;
324        }
325        let entry = self.trend_poll.0.get_mut(&oid).unwrap();
326        if !accepted {
327            entry.retry_completed = Some(completed);
328            return;
329        }
330        entry.last_success = Some(completed);
331        entry.retry_completed = None;
332        if let Some(boundary) = boundary {
333            entry.alignment.serve(boundary);
334        }
335    }
336
337    /// One reference's value, or the failure that stopped its read
338    /// (Clause 12.25 Log_Buffer; Clause 12.30.19 for each member).
339    fn acquire(&self, local: LocalDevice, member: &Member) -> LogValue {
340        match self.read_member(local, member) {
341            Ok((value, _)) => property_value_to_log_value(&value),
342            Err(failure) => failure,
343        }
344    }
345
346    /// One reference's value with the object it was read from, or the
347    /// failure that stopped its read, as [`acquire`](Self::acquire) logs it.
348    fn read_member(
349        &self,
350        local: LocalDevice,
351        member: &Member,
352    ) -> Result<(PropertyValue, &dyn BACnetObject), LogValue> {
353        let Member::Reference {
354            target,
355            property,
356            index,
357            device,
358        } = *member
359        else {
360            return Err(failure(
361                ErrorClass::PROPERTY,
362                ErrorCode::NO_PROPERTY_SPECIFIED,
363            ));
364        };
365        if !local.is_local(device) {
366            // Reading another device's property is not supported here.
367            return Err(failure(
368                ErrorClass::PROPERTY,
369                ErrorCode::OPTIONAL_FUNCTIONALITY_NOT_SUPPORTED,
370            ));
371        }
372        let Some(target) = self.get(&target) else {
373            return Err(failure(ErrorClass::OBJECT, ErrorCode::UNKNOWN_OBJECT));
374        };
375        // ReadProperty's gate, which the Averaging sampler shares: only an
376        // array has elements, whatever an object's read arm does with the
377        // index.
378        if index.is_some() && !target.is_array_property(property) {
379            return Err(failure(
380                ErrorClass::PROPERTY,
381                ErrorCode::PROPERTY_IS_NOT_AN_ARRAY,
382            ));
383        }
384        match target.read_property(property, index) {
385            Ok(value) => Ok((value, target)),
386            Err(
387                Error::Remote(RemoteError::Error { class, code })
388                | Error::Structured { class, code, .. },
389            ) => Err(failure(class, code)),
390            // An error with no BACnet class and code reports as ReadProperty
391            // would answer it.
392            Err(_) => Err(failure(ErrorClass::SERVICES, ErrorCode::OTHER)),
393        }
394    }
395}
396
397fn failure(class: ErrorClass, code: ErrorCode) -> LogValue {
398    LogValue::Failure {
399        error_class: class,
400        error_code: code,
401    }
402}
403
404/// Whether `new` differs from `old` only in a COV log's increment.
405fn only_increment_changed(old: &Configuration, new: &Configuration) -> bool {
406    matches!((old.mode, new.mode), (Mode::Cov { .. }, Mode::Cov { .. }))
407        && old.reference == new.reference
408        && old.members == new.members
409}
410
411/// What a log's properties ask of the poller now; `None` when nothing.
412fn configuration(object: &dyn BACnetObject) -> Option<Configuration> {
413    let read = |property| object.read_property(property, None).ok();
414    let logging_type = match read(P::LOGGING_TYPE) {
415        Some(PropertyValue::Enumerated(value)) => LoggingType::from_raw(value),
416        _ => LoggingType::POLLED,
417    };
418    let mode = match logging_type {
419        // An object without a Trigger row is never triggered.
420        LoggingType::TRIGGERED => {
421            (read(P::TRIGGER) == Some(PropertyValue::Boolean(true))).then_some(Mode::Triggered)?
422        }
423        LoggingType::POLLED => {
424            let Some(PropertyValue::Unsigned(raw)) = read(P::LOG_INTERVAL) else {
425                return None;
426            };
427            let interval = u32::try_from(raw).ok().filter(|value| *value > 0)?;
428            let offset = match read(P::INTERVAL_OFFSET) {
429                Some(PropertyValue::Unsigned(offset)) => (offset % u64::from(interval)) as u32,
430                _ => 0,
431            };
432            let aligned = read(P::ALIGN_INTERVALS) == Some(PropertyValue::Boolean(true));
433            // Clause 12.30.14 aligns only an interval that goes evenly into
434            // one of its clock periods; each of those goes evenly into a day,
435            // so going evenly into a day is the same test.
436            if aligned && DAY.is_multiple_of(interval) {
437                Mode::Aligned { interval, offset }
438            } else {
439                Mode::Polled { interval }
440            }
441        }
442        // Only a Trend Log takes COV; NULL is the target's own criteria.
443        LoggingType::COV => Mode::Cov {
444            increment: match read(P::CLIENT_COV_INCREMENT) {
445                Some(PropertyValue::Real(increment)) => Some(increment),
446                _ => None,
447            },
448        },
449        _ => return None,
450    };
451    let reference = read(P::LOG_DEVICE_OBJECT_PROPERTY)?;
452    let members = if object.object_identifier().object_type() == ObjectType::TREND_LOG_MULTIPLE {
453        let PropertyValue::List(elements) = &reference else {
454            return None;
455        };
456        elements.iter().map(member).collect::<Option<Vec<_>>>()?
457    } else {
458        match member(&reference)? {
459            // A log with no reference polls nothing. A Trigger on one is
460            // still served, with a failure, so it doesn't stay TRUE.
461            Member::Unspecified if mode != Mode::Triggered => return None,
462            member => vec![member],
463        }
464    };
465    // A Trigger is served even with no members, so it never stays TRUE.
466    if members.is_empty() && mode != Mode::Triggered {
467        return None;
468    }
469    Some(Configuration {
470        mode,
471        reference,
472        members,
473    })
474}
475
476/// One reference as a read serves it: a single
477/// BACnetDeviceObjectPropertyReference in its Clause 21 encoding (#1234).
478/// Anything else leaves the log unpolled. An unset reference, its object or
479/// Device at instance 4194303, names nothing: an empty Trend Log Multiple
480/// element (Clause 12.30.11), or a Trend Log without a reference (#1417).
481fn member(value: &PropertyValue) -> Option<Member> {
482    let reference: BACnetDeviceObjectPropertyReference = decode_reference(value).ok()?;
483    if reference.is_unset() {
484        return Some(Member::Unspecified);
485    }
486    Some(Member::Reference {
487        target: reference.object_identifier,
488        property: P::from_raw(reference.property_identifier),
489        index: reference.property_array_index,
490        device: reference.device_identifier,
491    })
492}
493
494/// A read value as the log stores it: one of the datatypes both record kinds
495/// name, or else the any-value alternative holding the value's own encoding,
496/// the bytes a ReadProperty of it would carry (Clauses 12.25.14 and
497/// 12.30.19).
498///
499/// An any-value over [`ANY_VALUE_MAX_OCTETS`] logs PROPERTY /
500/// VALUE_TOO_LONG instead, so every record stays small enough to page. A
501/// value no record could carry (one that fails to encode, a bit string with
502/// impossible padding, framed bytes whose tags don't balance) logs SERVICES
503/// / OTHER, so the log never refuses what the poller hands it.
504fn property_value_to_log_value(value: &PropertyValue) -> LogValue {
505    let value = match value {
506        PropertyValue::Real(v) => LogValue::RealValue(*v),
507        PropertyValue::Unsigned(v) => LogValue::UnsignedValue(*v),
508        PropertyValue::Signed(v) => LogValue::SignedValue(i64::from(*v)),
509        PropertyValue::Boolean(v) => LogValue::BooleanValue(*v),
510        PropertyValue::Enumerated(v) => LogValue::EnumValue(u64::from(*v)),
511        PropertyValue::BitString { unused_bits, data } => LogValue::BitstringValue {
512            unused_bits: *unused_bits,
513            data: data.clone(),
514        },
515        PropertyValue::Null => LogValue::NullValue,
516        other => {
517            let mut encoded = BytesMut::new();
518            if encode_property_value(&mut encoded, other).is_err() {
519                // A value the read could not have carried either.
520                return failure(ErrorClass::SERVICES, ErrorCode::OTHER);
521            }
522            if encoded.len() > ANY_VALUE_MAX_OCTETS {
523                return failure(ErrorClass::PROPERTY, ErrorCode::VALUE_TOO_LONG);
524            }
525            LogValue::AnyValue(encoded.to_vec())
526        }
527    };
528    if !matches!(
529        value,
530        LogValue::BitstringValue { .. } | LogValue::AnyValue(_)
531    ) {
532        return value;
533    }
534    // Only these two alternatives can hold something no record carries;
535    // encoding a one-member record tells.
536    let probe = BACnetLogMultipleRecord {
537        date: Date {
538            year: 0,
539            month: 1,
540            day: 1,
541            day_of_week: 1,
542        },
543        time: Time {
544            hour: 0,
545            minute: 0,
546            second: 0,
547            hundredths: 0,
548        },
549        log_data: LogData::Values(vec![value.clone()]),
550    };
551    match encode_log_multiple_record(&probe, &mut BytesMut::new()) {
552        Ok(()) => value,
553        Err(_) => failure(ErrorClass::SERVICES, ErrorCode::OTHER),
554    }
555}
556
557#[cfg(test)]
558mod tests;