Skip to main content

bacnet_server/
trend_log.rs

1//! Automatic trend logging.
2//!
3//! The server spawns a 1-second polling loop that checks each TrendLog object
4//! whose `log_interval > 0` and logs the monitored property when the interval
5//! elapses.
6
7use std::collections::HashMap;
8use std::sync::Arc;
9use std::time::Instant;
10
11use tokio::sync::RwLock;
12use tracing::warn;
13
14use bacnet_objects::database::ObjectDatabase;
15use bacnet_types::constructed::{BACnetLogRecord, LogDatum};
16
17/// Convert days since 1970-01-01 to (year_offset_from_1900, month, day, day_of_week).
18/// BACnet Date: year = offset from 1900, month = 1-12, day = 1-31, dow = 1(Mon)-7(Sun).
19fn days_to_date(total_days: u64) -> (u8, u8, u8, u8) {
20    // Day of week: 1970-01-01 was Thursday (4). BACnet: 1=Mon, 4=Thu.
21    let dow = ((total_days + 3) % 7) as u8 + 1; // 1=Mon..7=Sun
22
23    // Civil date from days since epoch (Euclidean affine algorithm)
24    let z = total_days as i64;
25    let era = z.div_euclid(146097);
26    let doe = z.rem_euclid(146097);
27    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
28    let y = yoe + era * 400 + 1970;
29    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
30    let mp = (5 * doy + 2) / 153;
31    let d = doy - (153 * mp + 2) / 5 + 1;
32    let m = if mp < 10 { mp + 3 } else { mp - 9 };
33    let y = if m <= 2 { y + 1 } else { y };
34
35    // BACnet year is offset from 1900
36    let year = (y - 1900).clamp(0, 255) as u8;
37    (year, m as u8, d as u8, dow)
38}
39use bacnet_types::enums::{ObjectType, PropertyIdentifier};
40use bacnet_types::primitives::{Date, ObjectIdentifier, PropertyValue, Time};
41
42/// Shared polling state — tracks last log time per TrendLog object.
43/// Stored in the server struct (not a global static) for testability.
44pub type TrendLogState = Arc<tokio::sync::Mutex<HashMap<ObjectIdentifier, Instant>>>;
45
46/// Convert a `PropertyValue` to a `LogDatum`.
47fn property_value_to_log_datum(pv: &PropertyValue) -> LogDatum {
48    match pv {
49        PropertyValue::Real(v) => LogDatum::RealValue(*v),
50        PropertyValue::Unsigned(v) => LogDatum::UnsignedValue(*v),
51        PropertyValue::Signed(v) => LogDatum::SignedValue(*v as i64),
52        PropertyValue::Boolean(v) => LogDatum::BooleanValue(*v),
53        PropertyValue::Enumerated(v) => LogDatum::EnumValue(*v),
54        _ => LogDatum::NullValue,
55    }
56}
57
58/// Create a `BACnetLogRecord` with the current wall-clock time.
59fn make_record(datum: LogDatum) -> BACnetLogRecord {
60    let now = {
61        let dur = std::time::SystemTime::now()
62            .duration_since(std::time::UNIX_EPOCH)
63            .unwrap_or_default();
64        let secs = dur.as_secs();
65        // Compute date from days since 1970-01-01
66        let total_days = secs / 86400;
67        let (year, month, day, dow) = days_to_date(total_days);
68        let hour = ((secs % 86400) / 3600) as u8;
69        let minute = ((secs % 3600) / 60) as u8;
70        let second = (secs % 60) as u8;
71        (year, month, day, dow, hour, minute, second)
72    };
73    BACnetLogRecord {
74        date: Date {
75            year: now.0,
76            month: now.1,
77            day: now.2,
78            day_of_week: now.3,
79        },
80        time: Time {
81            hour: now.4,
82            minute: now.5,
83            second: now.6,
84            hundredths: 0,
85        },
86        log_datum: datum,
87        status_flags: None,
88    }
89}
90
91/// Called every second by the server's trend-log polling task.
92///
93/// For each TrendLog with `log_interval > 0` (polled mode), checks whether
94/// enough time has elapsed since the last log entry and, if so, reads the
95/// monitored property and adds a record.
96pub async fn poll_trend_logs(db: &Arc<RwLock<ObjectDatabase>>, state: &TrendLogState) {
97    let mut last_log = state.lock().await;
98    let now = Instant::now();
99
100    let to_poll: Vec<(ObjectIdentifier, u32, ObjectIdentifier, u32)> = {
101        let db_read = db.read().await;
102        let trend_oids = db_read.find_by_type(ObjectType::TREND_LOG);
103        let mut result = Vec::new();
104        for oid in trend_oids {
105            if let Some(obj) = db_read.get(&oid) {
106                let log_interval = match obj.read_property(PropertyIdentifier::LOG_INTERVAL, None) {
107                    Ok(PropertyValue::Unsigned(v)) if v > 0 => v as u32,
108                    _ => continue,
109                };
110
111                let logging_type = match obj.read_property(PropertyIdentifier::LOGGING_TYPE, None) {
112                    Ok(PropertyValue::Enumerated(v)) => v,
113                    _ => 0,
114                };
115
116                if logging_type == 1 {
117                    warn!(object = %oid, "COV-based trend logging not yet implemented");
118                    continue;
119                }
120                if logging_type == 2 {
121                    warn!(object = %oid, "Triggered trend logging not yet implemented");
122                    continue;
123                }
124
125                let monitored_ref =
126                    match obj.read_property(PropertyIdentifier::LOG_DEVICE_OBJECT_PROPERTY, None) {
127                        Ok(PropertyValue::List(ref items)) if items.len() >= 2 => {
128                            let target_oid = match &items[0] {
129                                PropertyValue::ObjectIdentifier(o) => *o,
130                                _ => continue,
131                            };
132                            let prop_id = match &items[1] {
133                                PropertyValue::Unsigned(v) => *v as u32,
134                                _ => continue,
135                            };
136                            (target_oid, prop_id)
137                        }
138                        _ => continue,
139                    };
140
141                let elapsed = last_log
142                    .get(&oid)
143                    .map(|t| now.duration_since(*t).as_secs() as u32)
144                    .unwrap_or(u32::MAX);
145
146                if elapsed >= log_interval {
147                    result.push((oid, log_interval, monitored_ref.0, monitored_ref.1));
148                }
149            }
150        }
151        result
152    };
153
154    if to_poll.is_empty() {
155        return;
156    }
157
158    let mut db_write = db.write().await;
159    for (trend_oid, _interval, target_oid, prop_id) in to_poll {
160        let datum = if let Some(target_obj) = db_write.get(&target_oid) {
161            match target_obj.read_property(PropertyIdentifier::from_raw(prop_id), None) {
162                Ok(pv) => property_value_to_log_datum(&pv),
163                Err(_) => LogDatum::NullValue,
164            }
165        } else {
166            LogDatum::NullValue
167        };
168
169        let record = make_record(datum);
170
171        if let Some(trend_obj) = db_write.get_mut(&trend_oid) {
172            trend_obj.add_trend_record(record);
173        }
174
175        last_log.insert(trend_oid, now);
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn property_value_to_datum_real() {
185        let pv = PropertyValue::Real(42.5);
186        match property_value_to_log_datum(&pv) {
187            LogDatum::RealValue(v) => assert!((v - 42.5).abs() < f32::EPSILON),
188            other => panic!("expected RealValue, got {:?}", other),
189        }
190    }
191
192    #[test]
193    fn property_value_to_datum_unsigned() {
194        let pv = PropertyValue::Unsigned(100);
195        match property_value_to_log_datum(&pv) {
196            LogDatum::UnsignedValue(v) => assert_eq!(v, 100),
197            other => panic!("expected UnsignedValue, got {:?}", other),
198        }
199    }
200
201    #[test]
202    fn property_value_to_datum_boolean() {
203        let pv = PropertyValue::Boolean(true);
204        match property_value_to_log_datum(&pv) {
205            LogDatum::BooleanValue(v) => assert!(v),
206            other => panic!("expected BooleanValue, got {:?}", other),
207        }
208    }
209
210    #[test]
211    fn make_record_has_valid_time() {
212        let record = make_record(LogDatum::RealValue(1.0));
213        // Time fields should be within valid ranges
214        assert!(record.time.hour < 24);
215        assert!(record.time.minute < 60);
216        assert!(record.time.second < 60);
217    }
218}