use bacnet_encoding::constructed::{decode_name_value, encode_name_value};
use bacnet_encoding::tags::Tag;
use bacnet_types::constructed::BACnetNameValue;
use bacnet_types::enums::{ErrorClass, ErrorCode, PropertyIdentifier as P};
use bacnet_types::error::Error;
use bacnet_types::primitives::PropertyValue;
use bytes::BytesMut;
use crate::common;
use crate::property_metadata::{
PropertyConformance::Optional,
PropertyMetadata,
PropertyWriteCapability::{Always, ReadOnly},
};
mod persistence;
mod saving;
pub use persistence::{
FileTagsPersistence, TagsPersistence, TagsSnapshot, MAX_TAGS_SNAPSHOT_BYTES,
};
pub(crate) use saving::ProfileState;
pub const MAX_TAGS: usize = 1024;
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ObjectProfile {
pub tags: Option<Vec<BACnetNameValue>>,
pub profile_location: Option<String>,
pub profile_name: Option<String>,
}
impl ObjectProfile {
pub fn check(&self) -> Result<(), Error> {
if let Some(tags) = &self.tags {
check_tags(tags)?;
}
if self
.profile_location
.as_deref()
.is_some_and(|location| !location_scheme_allowed(location))
{
return Err(common::value_out_of_range_error());
}
if self
.profile_name
.as_deref()
.is_some_and(|name| !vendor_prefixed(name))
{
return Err(common::value_out_of_range_error());
}
Ok(())
}
pub(crate) fn metadata(&self) -> impl Iterator<Item = PropertyMetadata> {
[
(P::TAGS, self.tags.is_some(), Always),
(
P::PROFILE_LOCATION,
self.profile_location.is_some(),
ReadOnly,
),
(P::PROFILE_NAME, self.profile_name.is_some(), ReadOnly),
]
.into_iter()
.filter(|&(_, present, _)| present)
.map(|(property, _, write)| PropertyMetadata::new(property, Optional, None, write))
}
pub(crate) fn read(
&self,
property: P,
index: Option<u32>,
) -> Option<Result<PropertyValue, Error>> {
let text = |value: &Option<String>| match value {
None => Err(common::unknown_property_error()),
Some(_) if index.is_some() => Err(common::property_is_not_an_array_error()),
Some(text) => Ok(PropertyValue::CharacterString(text.clone())),
};
Some(match property {
P::TAGS => match &self.tags {
None => Err(common::unknown_property_error()),
Some(tags) => read_tags(tags, index),
},
P::PROFILE_LOCATION => text(&self.profile_location),
P::PROFILE_NAME => text(&self.profile_name),
_ => return None,
})
}
pub(crate) fn write(
&mut self,
property: P,
index: Option<u32>,
value: &PropertyValue,
) -> Option<Result<(), Error>> {
let read_only = |present: bool| match index {
_ if !present => Err(common::unknown_property_error()),
Some(_) => Err(common::property_is_not_an_array_error()),
None => Err(common::write_access_denied_error()),
};
Some(match property {
P::TAGS => match &mut self.tags {
None => Err(common::unknown_property_error()),
Some(tags) => write_tags(tags, index, value),
},
P::PROFILE_LOCATION => read_only(self.profile_location.is_some()),
P::PROFILE_NAME => read_only(self.profile_name.is_some()),
_ => return None,
})
}
}
fn encoded(tag: &BACnetNameValue) -> Result<PropertyValue, Error> {
let mut bytes = BytesMut::new();
encode_name_value(&mut bytes, tag)?;
Ok(PropertyValue::ApplicationData(bytes.to_vec()))
}
fn read_tags(tags: &[BACnetNameValue], index: Option<u32>) -> Result<PropertyValue, Error> {
match index {
None => Ok(PropertyValue::List(
tags.iter().map(encoded).collect::<Result<_, _>>()?,
)),
Some(0) => Ok(PropertyValue::Unsigned(tags.len() as u64)),
Some(index) => encoded(&tags[slot(index, tags.len())?]),
}
}
fn slot(index: u32, len: usize) -> Result<usize, Error> {
usize::try_from(index - 1)
.ok()
.filter(|slot| *slot < len)
.ok_or_else(common::invalid_array_index_error)
}
fn starts_name_value(tag: &Tag) -> bool {
tag.is_context(0)
}
fn no_space_error() -> Error {
common::protocol_error(ErrorClass::RESOURCES, ErrorCode::NO_SPACE_TO_WRITE_PROPERTY)
}
fn write_tags(
tags: &mut Vec<BACnetNameValue>,
index: Option<u32>,
value: &PropertyValue,
) -> Result<(), Error> {
match index {
None => {
let written = common::decode_elements(value, starts_name_value, decode_name_value)?;
check_tags(&written)?;
*tags = written;
}
Some(0) => {
let PropertyValue::Unsigned(size) = value else {
return Err(common::invalid_data_type_error());
};
let size = usize::try_from(*size)
.ok()
.filter(|size| *size <= MAX_TAGS)
.ok_or_else(no_space_error)?;
tags.resize_with(size, || BACnetNameValue::semantic(""));
}
Some(index) => {
let slot = slot(index, tags.len())?;
let tag = common::decode_single_element(value, starts_name_value, decode_name_value)?;
check_tag(&tag)?;
tags[slot] = tag;
}
}
Ok(())
}
fn check_tags(tags: &[BACnetNameValue]) -> Result<(), Error> {
if tags.len() > MAX_TAGS {
return Err(no_space_error());
}
tags.iter().try_for_each(check_tag)
}
fn check_tag(tag: &BACnetNameValue) -> Result<(), Error> {
if let Some(value) = &tag.value {
if !value.is_primitive() {
return Err(common::invalid_data_type_error());
}
}
if tag.name.contains(';') {
return Err(common::value_out_of_range_error());
}
Ok(())
}
fn location_scheme_allowed(location: &str) -> bool {
location.is_empty()
|| location.split_once(':').is_some_and(|(scheme, _)| {
["http", "https", "bacnet"]
.iter()
.any(|allowed| scheme.eq_ignore_ascii_case(allowed))
})
}
fn vendor_prefixed(name: &str) -> bool {
name.split_once('-').is_some_and(|(vendor, _)| {
!vendor.is_empty()
&& vendor.bytes().all(|b| b.is_ascii_digit())
&& vendor.parse::<u16>().is_ok()
})
}
#[cfg(test)]
#[path = "object_profile_tests.rs"]
mod tests;
#[cfg(test)]
mod persistence_tests;
#[cfg(test)]
mod saving_tests;
#[cfg(test)]
mod test_support;
#[cfg(test)]
mod value_tests;
#[cfg(test)]
mod input_tests;
#[cfg(test)]
mod output_tests;
#[cfg(test)]
mod device_tests;