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 crate::prelude::*;
35use bacnet_encoding::constructed::{decode_name_value, encode_name_value};
36use bacnet_encoding::tags::Tag;
37use bacnet_types::constructed::BACnetNameValue;
38use bacnet_types::enums::{ErrorClass, ErrorCode, PropertyIdentifier as P};
39use bacnet_types::error::Error;
40use bacnet_types::primitives::PropertyValue;
41use bytes::BytesMut;
42
43use crate::common;
44use crate::property_metadata::{
45    PropertyConformance::Optional,
46    PropertyMetadata,
47    PropertyWriteCapability::{Always, ReadOnly},
48};
49
50mod persistence;
51mod saving;
52#[cfg(feature = "std")]
53pub use persistence::FileTagsPersistence;
54pub use persistence::{MAX_TAGS_SNAPSHOT_BYTES, TagsPersistence, TagsSnapshot};
55pub(crate) use saving::ProfileState;
56
57/// Resource cap on Tags elements, the bound Exception_Schedule and a
58/// Calendar's Date_List have. A longer array is NO_SPACE_TO_WRITE_PROPERTY.
59pub const MAX_TAGS: usize = 1024;
60
61/// The optional Tags, Profile_Location and Profile_Name rows of one object.
62///
63/// A field left `None` keeps its row out of the object; `Some` serves it,
64/// an empty Tags array included. Provision it with the object's
65/// `set_profile`, which [`check`](Self::check)s it first.
66#[derive(Debug, Clone, Default, PartialEq)]
67pub struct ObjectProfile {
68    /// Tags: the object's tags, writable by peers.
69    pub tags: Option<Vec<BACnetNameValue>>,
70    /// Profile_Location: the URI of the file defining the profile.
71    pub profile_location: Option<String>,
72    /// Profile_Name: the profile the object conforms to.
73    pub profile_name: Option<String>,
74}
75
76impl ObjectProfile {
77    /// Check the rows as an object takes them. Too many tags is
78    /// NO_SPACE_TO_WRITE_PROPERTY; a tag value that isn't a primitive is
79    /// INVALID_DATA_TYPE; a tag name with a semicolon, a non-empty location
80    /// with another URI scheme, or a profile name without its vendor prefix
81    /// is VALUE_OUT_OF_RANGE.
82    pub fn check(&self) -> Result<(), Error> {
83        if let Some(tags) = &self.tags {
84            check_tags(tags)?;
85        }
86        if self
87            .profile_location
88            .as_deref()
89            .is_some_and(|location| !location_scheme_allowed(location))
90        {
91            return Err(common::value_out_of_range_error());
92        }
93        if self
94            .profile_name
95            .as_deref()
96            .is_some_and(|name| !vendor_prefixed(name))
97        {
98            return Err(common::value_out_of_range_error());
99        }
100        Ok(())
101    }
102
103    /// The provisioned rows, in the tables' order: Tags, Profile_Location,
104    /// Profile_Name.
105    pub(crate) fn metadata(&self) -> impl Iterator<Item = PropertyMetadata> + use<> {
106        [
107            (P::TAGS, self.tags.is_some(), Always),
108            (
109                P::PROFILE_LOCATION,
110                self.profile_location.is_some(),
111                ReadOnly,
112            ),
113            (P::PROFILE_NAME, self.profile_name.is_some(), ReadOnly),
114        ]
115        .into_iter()
116        .filter(|&(_, present, _)| present)
117        .map(|(property, _, write)| PropertyMetadata::new(property, Optional, None, write))
118    }
119
120    /// Read one of the three rows; `None` for any other property.
121    pub(crate) fn read(
122        &self,
123        property: P,
124        index: Option<u32>,
125    ) -> Option<Result<PropertyValue, Error>> {
126        let text = |value: &Option<String>| match value {
127            None => Err(common::unknown_property_error()),
128            Some(_) if index.is_some() => Err(common::property_is_not_an_array_error()),
129            Some(text) => Ok(PropertyValue::CharacterString(text.clone())),
130        };
131        Some(match property {
132            P::TAGS => match &self.tags {
133                None => Err(common::unknown_property_error()),
134                Some(tags) => read_tags(tags, index),
135            },
136            P::PROFILE_LOCATION => text(&self.profile_location),
137            P::PROFILE_NAME => text(&self.profile_name),
138            _ => return None,
139        })
140    }
141
142    /// Write one of the three rows; `None` for any other property. A
143    /// refused write changes nothing.
144    pub(crate) fn write(
145        &mut self,
146        property: P,
147        index: Option<u32>,
148        value: &PropertyValue,
149    ) -> Option<Result<(), Error>> {
150        let read_only = |present: bool| match index {
151            _ if !present => Err(common::unknown_property_error()),
152            Some(_) => Err(common::property_is_not_an_array_error()),
153            None => Err(common::write_access_denied_error()),
154        };
155        Some(match property {
156            P::TAGS => match &mut self.tags {
157                None => Err(common::unknown_property_error()),
158                Some(tags) => write_tags(tags, index, value),
159            },
160            P::PROFILE_LOCATION => read_only(self.profile_location.is_some()),
161            P::PROFILE_NAME => read_only(self.profile_name.is_some()),
162            _ => return None,
163        })
164    }
165}
166
167/// One tag as the array element a read returns.
168fn encoded(tag: &BACnetNameValue) -> Result<PropertyValue, Error> {
169    let mut bytes = BytesMut::new();
170    encode_name_value(&mut bytes, tag)?;
171    Ok(PropertyValue::ApplicationData(bytes.to_vec()))
172}
173
174fn read_tags(tags: &[BACnetNameValue], index: Option<u32>) -> Result<PropertyValue, Error> {
175    match index {
176        None => Ok(PropertyValue::List(
177            tags.iter().map(encoded).collect::<Result<_, _>>()?,
178        )),
179        Some(0) => Ok(PropertyValue::Unsigned(tags.len() as u64)),
180        Some(index) => encoded(&tags[slot(index, tags.len())?]),
181    }
182}
183
184/// The zero-based slot of a one-based array index below `len`, or
185/// INVALID_ARRAY_INDEX.
186fn slot(index: u32, len: usize) -> Result<usize, Error> {
187    usize::try_from(index - 1)
188        .ok()
189        .filter(|slot| *slot < len)
190        .ok_or_else(common::invalid_array_index_error)
191}
192
193/// A BACnetNameValue opens with its name under context tag 0.
194fn starts_name_value(tag: &Tag) -> bool {
195    tag.is_context(0)
196}
197
198fn no_space_error() -> Error {
199    common::protocol_error(ErrorClass::RESOURCES, ErrorCode::NO_SPACE_TO_WRITE_PROPERTY)
200}
201
202/// WriteProperty of Tags: the whole array, its size at index 0, or one
203/// element. The index is checked before the value (Clause 12.1.5.1: an index
204/// past the end doesn't grow the array).
205fn write_tags(
206    tags: &mut Vec<BACnetNameValue>,
207    index: Option<u32>,
208    value: &PropertyValue,
209) -> Result<(), Error> {
210    match index {
211        None => {
212            let written = common::decode_elements(value, starts_name_value, decode_name_value)?;
213            check_tags(&written)?;
214            *tags = written;
215        }
216        Some(0) => {
217            let PropertyValue::Unsigned(size) = value else {
218                return Err(common::invalid_data_type_error());
219            };
220            let size = usize::try_from(*size)
221                .ok()
222                .filter(|size| *size <= MAX_TAGS)
223                .ok_or_else(no_space_error)?;
224            tags.resize_with(size, || BACnetNameValue::semantic(""));
225        }
226        Some(index) => {
227            let slot = slot(index, tags.len())?;
228            let tag = common::decode_single_element(value, starts_name_value, decode_name_value)?;
229            check_tag(&tag)?;
230            tags[slot] = tag;
231        }
232    }
233    Ok(())
234}
235
236fn check_tags(tags: &[BACnetNameValue]) -> Result<(), Error> {
237    if tags.len() > MAX_TAGS {
238        return Err(no_space_error());
239    }
240    tags.iter().try_for_each(check_tag)
241}
242
243/// Annex Y.1.4 keeps semicolons out of tag names, so names can be joined.
244fn check_tag(tag: &BACnetNameValue) -> Result<(), Error> {
245    if let Some(value) = &tag.value
246        && !value.is_primitive()
247    {
248        return Err(common::invalid_data_type_error());
249    }
250    if tag.name.contains(';') {
251        return Err(common::value_out_of_range_error());
252    }
253    Ok(())
254}
255
256/// Whether a Profile_Location is empty, which the Profile_Name subclause
257/// allows for, or a URI using one of the schemes its own subclause allows.
258fn location_scheme_allowed(location: &str) -> bool {
259    location.is_empty()
260        || location.split_once(':').is_some_and(|(scheme, _)| {
261            ["http", "https", "bacnet"]
262                .iter()
263                .any(|allowed| scheme.eq_ignore_ascii_case(allowed))
264        })
265}
266
267/// Whether a Profile_Name begins with a vendor identifier in decimal and a
268/// dash.
269fn vendor_prefixed(name: &str) -> bool {
270    name.split_once('-').is_some_and(|(vendor, _)| {
271        !vendor.is_empty()
272            && vendor.bytes().all(|b| b.is_ascii_digit())
273            && vendor.parse::<u16>().is_ok()
274    })
275}
276
277#[cfg(test)]
278#[path = "object_profile_tests.rs"]
279mod tests;
280
281#[cfg(all(test, feature = "std"))]
282mod persistence_tests;
283#[cfg(test)]
284mod saving_tests;
285#[cfg(test)]
286mod test_support;
287
288#[cfg(test)]
289mod value_tests;
290
291#[cfg(test)]
292mod input_tests;
293
294#[cfg(test)]
295mod output_tests;
296
297#[cfg(test)]
298mod device_tests;