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