Skip to main content

bacnet_objects/
object_profile.rs

1//! The optional Tags, Profile_Location and Profile_Name rows (#1553).
2//!
3//! Most Clause 12 object tables list these three as optional rows. An object
4//! that carries an [`ObjectProfile`] serves the rows it provisions, before
5//! registration, as an [`ObjectAuditPolicy`](crate::audit::ObjectAuditPolicy)
6//! provisions the audit rows; an object that provisions none serves none,
7//! exactly as before. The Color, Color Temperature, Lighting Output and
8//! Binary Lighting Output, Analog Value, Binary Value, Multi-state Value, Analog
9//! Input, Binary Input, Multi-state Input, Analog Output, Binary Output and
10//! Multi-state Output and Device objects carry one (`set_profile`
11//! on each). Value profile rows are independent of Present_Value access and
12//! source tracking; input profiles are independent of Out_Of_Service, and output
13//! profiles are independent of command priorities, sources and feedback. Device
14//! profiles preserve identity, clock, service declarations and Audit ownership.
15//! Endpoint responders retain their separate, narrow Device write admission.
16//!
17//! - Tags is a BACnetARRAY of BACnetNameValue that peers may write: whole, an
18//!   element by index, or its size at index 0, which truncates or appends
19//!   (Clause 12.1.5.1). An appended tag is a semantic tag with an empty
20//!   name, a local choice the clause leaves open. A tag name may not hold a
21//!   semicolon (Annex Y.1.4), and the array holds at most [`MAX_TAGS`].
22//! - Profile_Location and Profile_Name are the application's: read-only over
23//!   the network, as their O conformance code permits, so the PICS lists
24//!   them as readable only. Provisioning checks each against its subclause:
25//!   a location is empty or its URI scheme is http, https or bacnet, and a
26//!   profile name begins with a decimal vendor identifier and a dash.
27//!
28//! Objects built with `new` or `with_access` keep Tags in memory. Their
29//! `with_tags_persistence` constructors (access-explicit for Values) attach
30//! application-owned storage:
31//! successful writes then override configured Tags across reconstruction.
32//! Saved Tags never provision an absent row. See [`TagsPersistence`].
33
34use bacnet_encoding::constructed::{decode_name_value, encode_name_value};
35use bacnet_encoding::tags::Tag;
36use bacnet_types::constructed::BACnetNameValue;
37use bacnet_types::enums::{ErrorClass, ErrorCode, PropertyIdentifier as P};
38use bacnet_types::error::Error;
39use bacnet_types::primitives::PropertyValue;
40use bytes::BytesMut;
41
42use crate::common;
43use crate::property_metadata::{
44    PropertyConformance::Optional,
45    PropertyMetadata,
46    PropertyWriteCapability::{Always, ReadOnly},
47};
48
49mod persistence;
50mod saving;
51pub use persistence::{
52    FileTagsPersistence, TagsPersistence, TagsSnapshot, MAX_TAGS_SNAPSHOT_BYTES,
53};
54pub(crate) use saving::ProfileState;
55
56/// Resource cap on Tags elements, the bound Exception_Schedule and a
57/// Calendar's Date_List have. A longer array is NO_SPACE_TO_WRITE_PROPERTY.
58pub const MAX_TAGS: usize = 1024;
59
60/// The optional Tags, Profile_Location and Profile_Name rows of one object.
61///
62/// A field left `None` keeps its row out of the object; `Some` serves it,
63/// an empty Tags array included. Provision it with the object's
64/// `set_profile`, which [`check`](Self::check)s it first.
65#[derive(Debug, Clone, Default, PartialEq)]
66pub struct ObjectProfile {
67    /// Tags: the object's tags, writable by peers.
68    pub tags: Option<Vec<BACnetNameValue>>,
69    /// Profile_Location: the URI of the file defining the profile.
70    pub profile_location: Option<String>,
71    /// Profile_Name: the profile the object conforms to.
72    pub profile_name: Option<String>,
73}
74
75impl ObjectProfile {
76    /// Check the rows as an object takes them. Too many tags is
77    /// NO_SPACE_TO_WRITE_PROPERTY; a tag value that isn't a primitive is
78    /// INVALID_DATA_TYPE; a tag name with a semicolon, a non-empty location
79    /// with another URI scheme, or a profile name without its vendor prefix
80    /// is VALUE_OUT_OF_RANGE.
81    pub fn check(&self) -> Result<(), Error> {
82        if let Some(tags) = &self.tags {
83            check_tags(tags)?;
84        }
85        if self
86            .profile_location
87            .as_deref()
88            .is_some_and(|location| !location_scheme_allowed(location))
89        {
90            return Err(common::value_out_of_range_error());
91        }
92        if self
93            .profile_name
94            .as_deref()
95            .is_some_and(|name| !vendor_prefixed(name))
96        {
97            return Err(common::value_out_of_range_error());
98        }
99        Ok(())
100    }
101
102    /// The provisioned rows, in the tables' order: Tags, Profile_Location,
103    /// Profile_Name.
104    pub(crate) fn metadata(&self) -> impl Iterator<Item = PropertyMetadata> {
105        [
106            (P::TAGS, self.tags.is_some(), Always),
107            (
108                P::PROFILE_LOCATION,
109                self.profile_location.is_some(),
110                ReadOnly,
111            ),
112            (P::PROFILE_NAME, self.profile_name.is_some(), ReadOnly),
113        ]
114        .into_iter()
115        .filter(|&(_, present, _)| present)
116        .map(|(property, _, write)| PropertyMetadata::new(property, Optional, None, write))
117    }
118
119    /// Read one of the three rows; `None` for any other property.
120    pub(crate) fn read(
121        &self,
122        property: P,
123        index: Option<u32>,
124    ) -> Option<Result<PropertyValue, Error>> {
125        let text = |value: &Option<String>| match value {
126            None => Err(common::unknown_property_error()),
127            Some(_) if index.is_some() => Err(common::property_is_not_an_array_error()),
128            Some(text) => Ok(PropertyValue::CharacterString(text.clone())),
129        };
130        Some(match property {
131            P::TAGS => match &self.tags {
132                None => Err(common::unknown_property_error()),
133                Some(tags) => read_tags(tags, index),
134            },
135            P::PROFILE_LOCATION => text(&self.profile_location),
136            P::PROFILE_NAME => text(&self.profile_name),
137            _ => return None,
138        })
139    }
140
141    /// Write one of the three rows; `None` for any other property. A
142    /// refused write changes nothing.
143    pub(crate) fn write(
144        &mut self,
145        property: P,
146        index: Option<u32>,
147        value: &PropertyValue,
148    ) -> Option<Result<(), Error>> {
149        let read_only = |present: bool| match index {
150            _ if !present => Err(common::unknown_property_error()),
151            Some(_) => Err(common::property_is_not_an_array_error()),
152            None => Err(common::write_access_denied_error()),
153        };
154        Some(match property {
155            P::TAGS => match &mut self.tags {
156                None => Err(common::unknown_property_error()),
157                Some(tags) => write_tags(tags, index, value),
158            },
159            P::PROFILE_LOCATION => read_only(self.profile_location.is_some()),
160            P::PROFILE_NAME => read_only(self.profile_name.is_some()),
161            _ => return None,
162        })
163    }
164}
165
166/// One tag as the array element a read returns.
167fn encoded(tag: &BACnetNameValue) -> Result<PropertyValue, Error> {
168    let mut bytes = BytesMut::new();
169    encode_name_value(&mut bytes, tag)?;
170    Ok(PropertyValue::ApplicationData(bytes.to_vec()))
171}
172
173fn read_tags(tags: &[BACnetNameValue], index: Option<u32>) -> Result<PropertyValue, Error> {
174    match index {
175        None => Ok(PropertyValue::List(
176            tags.iter().map(encoded).collect::<Result<_, _>>()?,
177        )),
178        Some(0) => Ok(PropertyValue::Unsigned(tags.len() as u64)),
179        Some(index) => encoded(&tags[slot(index, tags.len())?]),
180    }
181}
182
183/// The zero-based slot of a one-based array index below `len`, or
184/// INVALID_ARRAY_INDEX.
185fn slot(index: u32, len: usize) -> Result<usize, Error> {
186    usize::try_from(index - 1)
187        .ok()
188        .filter(|slot| *slot < len)
189        .ok_or_else(common::invalid_array_index_error)
190}
191
192/// A BACnetNameValue opens with its name under context tag 0.
193fn starts_name_value(tag: &Tag) -> bool {
194    tag.is_context(0)
195}
196
197fn no_space_error() -> Error {
198    common::protocol_error(ErrorClass::RESOURCES, ErrorCode::NO_SPACE_TO_WRITE_PROPERTY)
199}
200
201/// WriteProperty of Tags: the whole array, its size at index 0, or one
202/// element. The index is checked before the value (Clause 12.1.5.1: an index
203/// past the end doesn't grow the array).
204fn write_tags(
205    tags: &mut Vec<BACnetNameValue>,
206    index: Option<u32>,
207    value: &PropertyValue,
208) -> Result<(), Error> {
209    match index {
210        None => {
211            let written = common::decode_elements(value, starts_name_value, decode_name_value)?;
212            check_tags(&written)?;
213            *tags = written;
214        }
215        Some(0) => {
216            let PropertyValue::Unsigned(size) = value else {
217                return Err(common::invalid_data_type_error());
218            };
219            let size = usize::try_from(*size)
220                .ok()
221                .filter(|size| *size <= MAX_TAGS)
222                .ok_or_else(no_space_error)?;
223            tags.resize_with(size, || BACnetNameValue::semantic(""));
224        }
225        Some(index) => {
226            let slot = slot(index, tags.len())?;
227            let tag = common::decode_single_element(value, starts_name_value, decode_name_value)?;
228            check_tag(&tag)?;
229            tags[slot] = tag;
230        }
231    }
232    Ok(())
233}
234
235fn check_tags(tags: &[BACnetNameValue]) -> Result<(), Error> {
236    if tags.len() > MAX_TAGS {
237        return Err(no_space_error());
238    }
239    tags.iter().try_for_each(check_tag)
240}
241
242/// Annex Y.1.4 keeps semicolons out of tag names, so names can be joined.
243fn check_tag(tag: &BACnetNameValue) -> Result<(), Error> {
244    if let Some(value) = &tag.value {
245        if !value.is_primitive() {
246            return Err(common::invalid_data_type_error());
247        }
248    }
249    if tag.name.contains(';') {
250        return Err(common::value_out_of_range_error());
251    }
252    Ok(())
253}
254
255/// Whether a Profile_Location is empty, which the Profile_Name subclause
256/// allows for, or a URI using one of the schemes its own subclause allows.
257fn location_scheme_allowed(location: &str) -> bool {
258    location.is_empty()
259        || location.split_once(':').is_some_and(|(scheme, _)| {
260            ["http", "https", "bacnet"]
261                .iter()
262                .any(|allowed| scheme.eq_ignore_ascii_case(allowed))
263        })
264}
265
266/// Whether a Profile_Name begins with a vendor identifier in decimal and a
267/// dash.
268fn vendor_prefixed(name: &str) -> bool {
269    name.split_once('-').is_some_and(|(vendor, _)| {
270        !vendor.is_empty()
271            && vendor.bytes().all(|b| b.is_ascii_digit())
272            && vendor.parse::<u16>().is_ok()
273    })
274}
275
276#[cfg(test)]
277#[path = "object_profile_tests.rs"]
278mod tests;
279
280#[cfg(test)]
281mod persistence_tests;
282#[cfg(test)]
283mod saving_tests;
284#[cfg(test)]
285mod test_support;
286
287#[cfg(test)]
288mod value_tests;
289
290#[cfg(test)]
291mod input_tests;
292
293#[cfg(test)]
294mod output_tests;
295
296#[cfg(test)]
297mod device_tests;