bacnet_objects/color/persistence.rs
1//! Keeping a colour object's Present_Value across a restart (#1526).
2//!
3//! Addendum 135-2020ca lets a zero default, Default_Color (0, 0) or a
4//! Default_Color_Temperature of 0, mean that the output comes back at the
5//! value it held before the restart (Clauses 12.X.8 and 12.Y.4). That needs
6//! Present_Value kept somewhere that outlives the process; as for the
7//! Notification Class ([`NotificationClassPersistence`]), the storage
8//! belongs to the application. The object loads it once, as it takes the
9//! storage, and saves each new Present_Value on its own writer thread
10//! ([`crate::durable`]), so no file I/O happens under the database guard.
11//!
12//! Unlike a Notification Class's Recipient_List, a Present_Value change
13//! doesn't wait for its save, and a save that fails doesn't refuse it: the
14//! value is a live output, which a command, a fade's STOP or the start of
15//! the device can move, and a light shouldn't refuse a colour because a
16//! disk is full. Saves coalesce, so a burst of changes costs one save of the
17//! newest value. A save that fails is logged, and tried again with the next
18//! change, as the server stops, and as the object is dropped.
19//!
20//! [`NotificationClassPersistence`]: crate::notification_class::NotificationClassPersistence
21
22use std::path::Path;
23
24use bacnet_types::constructed::BACnetXyColor;
25use bacnet_types::error::Error;
26use bacnet_types::primitives::ObjectIdentifier;
27
28use super::command;
29use crate::durable::file::ObjectFile;
30
31/// A colour output's Present_Value, as storage keeps it.
32#[derive(Clone, Copy, Debug, PartialEq)]
33pub enum ColorOutputValue {
34 /// A Color object's CIE 1931 xy colour.
35 Xy(BACnetXyColor),
36 /// A Color Temperature object's temperature in kelvin.
37 Kelvin(u32),
38}
39
40impl ColorOutputValue {
41 /// Whether the value is one the object could hold: both xy
42 /// coordinates 0.0 to 1.0, or a temperature of 1000 to 30000 K.
43 pub fn is_valid(&self) -> bool {
44 match *self {
45 Self::Xy(color) => command::xy_in_range(color),
46 Self::Kelvin(kelvin) => command::KELVIN.contains(&kelvin),
47 }
48 }
49}
50
51/// What a Color or Color Temperature object keeps across a restart.
52#[derive(Clone, Copy, Debug, PartialEq)]
53pub struct ColorOutputSnapshot {
54 /// The last Present_Value the object held.
55 pub present_value: ColorOutputValue,
56}
57
58/// Application-owned storage for one colour object's Present_Value.
59///
60/// The object calls `load` once, as it takes the storage, and `save` from
61/// its writer thread, one call at a time. A backend that refuses a save
62/// returns an error and keeps what it held.
63pub trait ColorOutputPersistence: Send + Sync {
64 /// What was last saved for `object`, or `None` when nothing was saved.
65 fn load(&self, object: ObjectIdentifier) -> Result<Option<ColorOutputSnapshot>, Error>;
66
67 /// Durably replace what is saved for `object` with `snapshot`.
68 fn save(&self, object: ObjectIdentifier, snapshot: &ColorOutputSnapshot) -> Result<(), Error>;
69}
70
71/// The format's tag.
72const MAGIC: &[u8; 8] = b"RBNCLR01";
73/// The body is at most a kind octet and two REALs; anything much larger is
74/// not a file this backend wrote.
75const MAX_FILE_BYTES: u64 = 64;
76/// The kind octet of an xy colour, followed by x and y as big-endian IEEE
77/// 754 single-precision values.
78const KIND_XY: u8 = 1;
79/// The kind octet of a colour temperature, followed by the kelvin as a
80/// big-endian 32-bit unsigned value.
81const KIND_KELVIN: u8 = 2;
82
83/// A file holding one colour object's Present_Value, replaced whole on each
84/// save the same way as [`FileNotificationClassPersistence`]: a failed save
85/// leaves the previous value in place, and a completed one survives a power
86/// loss.
87///
88/// The file holds a magic tag, the object's identifier, one octet naming the
89/// kind of value, and the value: two big-endian REALs for an xy colour, a
90/// big-endian 32-bit kelvin for a temperature. A file of the wrong size, of
91/// the other kind, or with a value outside the object's range, does not
92/// load. It does not coordinate between processes.
93///
94/// [`FileNotificationClassPersistence`]: crate::notification_class::FileNotificationClassPersistence
95#[derive(Clone, Debug)]
96pub struct FileColorOutputPersistence {
97 file: ObjectFile,
98}
99
100impl FileColorOutputPersistence {
101 /// Keep the Present_Value in the file at `path`. Refuses an empty path.
102 pub fn new(path: impl AsRef<Path>) -> Result<Self, Error> {
103 Ok(Self {
104 file: ObjectFile::new(path.as_ref(), MAGIC, MAX_FILE_BYTES, "Color output")?,
105 })
106 }
107
108 /// The file the Present_Value is kept in.
109 pub fn path(&self) -> &Path {
110 self.file.path()
111 }
112}
113
114impl ColorOutputPersistence for FileColorOutputPersistence {
115 fn load(&self, object: ObjectIdentifier) -> Result<Option<ColorOutputSnapshot>, Error> {
116 let Some(body) = self.file.load(object)? else {
117 return Ok(None);
118 };
119 let present_value = match body.as_slice() {
120 [KIND_XY, x0, x1, x2, x3, y0, y1, y2, y3] => ColorOutputValue::Xy(BACnetXyColor::new(
121 f32::from_be_bytes([*x0, *x1, *x2, *x3]),
122 f32::from_be_bytes([*y0, *y1, *y2, *y3]),
123 )),
124 [KIND_KELVIN, k0, k1, k2, k3] => {
125 ColorOutputValue::Kelvin(u32::from_be_bytes([*k0, *k1, *k2, *k3]))
126 }
127 _ => return Err(self.file.corrupt("holds no valid value")),
128 };
129 if !present_value.is_valid() {
130 return Err(self.file.corrupt("holds a value out of range"));
131 }
132 Ok(Some(ColorOutputSnapshot { present_value }))
133 }
134
135 fn save(&self, object: ObjectIdentifier, snapshot: &ColorOutputSnapshot) -> Result<(), Error> {
136 let mut body = Vec::with_capacity(9);
137 match snapshot.present_value {
138 ColorOutputValue::Xy(color) => {
139 body.push(KIND_XY);
140 body.extend_from_slice(&color.x.to_be_bytes());
141 body.extend_from_slice(&color.y.to_be_bytes());
142 }
143 ColorOutputValue::Kelvin(kelvin) => {
144 body.push(KIND_KELVIN);
145 body.extend_from_slice(&kelvin.to_be_bytes());
146 }
147 }
148 self.file.save(object, &body)
149 }
150}