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;