Skip to main content

mabi_core/
device_builder.rs

1//! Device builder pattern for fluent device construction.
2//!
3//! This module provides a builder pattern for creating devices with
4//! a clean, chainable API that improves extensibility and maintainability.
5//!
6//! # Example
7//!
8//! ```rust,ignore
9//! use mabi_core::device_builder::{DeviceBuilder, MockDeviceBuilder};
10//!
11//! let device = MockDeviceBuilder::new("device-001", "Temperature Sensor")
12//!     .protocol(Protocol::ModbusTcp)
13//!     .description("Main building temperature sensor")
14//!     .metadata("location", "Building A, Floor 1")
15//!     .metadata("manufacturer", "ACME Sensors")
16//!     .add_point(DataPointDef::new("temp", "Temperature", DataType::Float32)
17//!         .with_units("°C")
18//!         .with_range(-40.0, 80.0))
19//!     .add_point(DataPointDef::new("humidity", "Humidity", DataType::Float32)
20//!         .with_units("%")
21//!         .with_range(0.0, 100.0))
22//!     .build()?;
23//! ```
24
25use std::collections::HashMap;
26
27use crate::config::DeviceConfig;
28use crate::error::{Error, Result};
29use crate::protocol::Protocol;
30use crate::types::{Address, DataPointDef, DataType};
31
32/// Builder for creating device configurations.
33///
34/// This builder provides a fluent API for constructing `DeviceConfig`
35/// instances with validation at build time.
36#[derive(Debug, Clone)]
37pub struct DeviceConfigBuilder {
38    id: String,
39    name: String,
40    description: String,
41    protocol: Protocol,
42    address: Option<String>,
43    points: Vec<DataPointDef>,
44    metadata: HashMap<String, String>,
45    validation_enabled: bool,
46}
47
48impl DeviceConfigBuilder {
49    /// Create a new device configuration builder.
50    ///
51    /// # Arguments
52    ///
53    /// * `id` - Unique device identifier
54    /// * `name` - Human-readable device name
55    pub fn new(id: impl Into<String>, name: impl Into<String>) -> Self {
56        Self {
57            id: id.into(),
58            name: name.into(),
59            description: String::new(),
60            protocol: Protocol::ModbusTcp,
61            address: None,
62            points: Vec::new(),
63            metadata: HashMap::new(),
64            validation_enabled: true,
65        }
66    }
67
68    /// Set the protocol for this device.
69    pub fn protocol(mut self, protocol: Protocol) -> Self {
70        self.protocol = protocol;
71        self
72    }
73
74    /// Set the device description.
75    pub fn description(mut self, description: impl Into<String>) -> Self {
76        self.description = description.into();
77        self
78    }
79
80    /// Set the protocol-specific address.
81    pub fn address(mut self, address: impl Into<String>) -> Self {
82        self.address = Some(address.into());
83        self
84    }
85
86    /// Add a metadata key-value pair.
87    pub fn metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
88        self.metadata.insert(key.into(), value.into());
89        self
90    }
91
92    /// Add multiple metadata entries.
93    pub fn with_metadata(mut self, metadata: HashMap<String, String>) -> Self {
94        self.metadata.extend(metadata);
95        self
96    }
97
98    /// Add a data point definition.
99    pub fn add_point(mut self, point: DataPointDef) -> Self {
100        self.points.push(point);
101        self
102    }
103
104    /// Add multiple data point definitions.
105    pub fn add_points(mut self, points: impl IntoIterator<Item = DataPointDef>) -> Self {
106        self.points.extend(points);
107        self
108    }
109
110    /// Disable validation at build time.
111    pub fn skip_validation(mut self) -> Self {
112        self.validation_enabled = false;
113        self
114    }
115
116    /// Validate the current builder state.
117    pub fn validate(&self) -> Result<()> {
118        if self.id.is_empty() {
119            return Err(Error::Config("Device ID cannot be empty".into()));
120        }
121        if self.name.is_empty() {
122            return Err(Error::Config("Device name cannot be empty".into()));
123        }
124
125        // Check for duplicate point IDs
126        let mut point_ids = std::collections::HashSet::new();
127        for point in &self.points {
128            if !point_ids.insert(&point.id) {
129                return Err(Error::Config(format!(
130                    "Duplicate data point ID: {}",
131                    point.id
132                )));
133            }
134        }
135
136        Ok(())
137    }
138
139    /// Build the device configuration.
140    pub fn build(self) -> Result<DeviceConfig> {
141        if self.validation_enabled {
142            self.validate()?;
143        }
144
145        Ok(DeviceConfig {
146            id: self.id,
147            name: self.name,
148            description: self.description,
149            protocol: self.protocol,
150            address: self.address,
151            points: self
152                .points
153                .into_iter()
154                .map(|p| crate::config::DataPointConfig {
155                    id: p.id,
156                    name: p.name,
157                    data_type: format!("{:?}", p.data_type).to_lowercase(),
158                    access: format!("{:?}", p.access).to_lowercase(),
159                    address: p.address.map(|a| format!("{:?}", a)),
160                    initial_value: p.default_value.map(|v| serde_json::to_value(v).unwrap()),
161                    units: p.units,
162                    min: p.min_value,
163                    max: p.max_value,
164                })
165                .collect(),
166            metadata: self.metadata,
167        })
168    }
169}
170
171/// Builder for data point definitions with chainable API.
172#[derive(Debug, Clone)]
173pub struct DataPointBuilder {
174    id: String,
175    name: String,
176    data_type: DataType,
177    description: String,
178    access: crate::types::AccessMode,
179    units: Option<String>,
180    min_value: Option<f64>,
181    max_value: Option<f64>,
182    default_value: Option<crate::value::Value>,
183    address: Option<Address>,
184}
185
186impl DataPointBuilder {
187    /// Create a new data point builder.
188    pub fn new(id: impl Into<String>, name: impl Into<String>, data_type: DataType) -> Self {
189        Self {
190            id: id.into(),
191            name: name.into(),
192            data_type,
193            description: String::new(),
194            access: crate::types::AccessMode::ReadWrite,
195            units: None,
196            min_value: None,
197            max_value: None,
198            default_value: None,
199            address: None,
200        }
201    }
202
203    /// Set the description.
204    pub fn description(mut self, description: impl Into<String>) -> Self {
205        self.description = description.into();
206        self
207    }
208
209    /// Set the access mode.
210    pub fn access(mut self, access: crate::types::AccessMode) -> Self {
211        self.access = access;
212        self
213    }
214
215    /// Set read-only access.
216    pub fn read_only(mut self) -> Self {
217        self.access = crate::types::AccessMode::ReadOnly;
218        self
219    }
220
221    /// Set write-only access.
222    pub fn write_only(mut self) -> Self {
223        self.access = crate::types::AccessMode::WriteOnly;
224        self
225    }
226
227    /// Set the engineering units.
228    pub fn units(mut self, units: impl Into<String>) -> Self {
229        self.units = Some(units.into());
230        self
231    }
232
233    /// Set the value range.
234    pub fn range(mut self, min: f64, max: f64) -> Self {
235        self.min_value = Some(min);
236        self.max_value = Some(max);
237        self
238    }
239
240    /// Set the minimum value.
241    pub fn min(mut self, min: f64) -> Self {
242        self.min_value = Some(min);
243        self
244    }
245
246    /// Set the maximum value.
247    pub fn max(mut self, max: f64) -> Self {
248        self.max_value = Some(max);
249        self
250    }
251
252    /// Set the default value.
253    pub fn default_value(mut self, value: impl Into<crate::value::Value>) -> Self {
254        self.default_value = Some(value.into());
255        self
256    }
257
258    /// Set the protocol-specific address.
259    pub fn address(mut self, address: Address) -> Self {
260        self.address = Some(address);
261        self
262    }
263
264    /// Build the data point definition.
265    pub fn build(self) -> DataPointDef {
266        DataPointDef {
267            id: self.id,
268            name: self.name,
269            description: self.description,
270            data_type: self.data_type,
271            access: self.access,
272            units: self.units,
273            min_value: self.min_value,
274            max_value: self.max_value,
275            default_value: self.default_value,
276            address: self.address,
277        }
278    }
279}
280
281impl From<DataPointBuilder> for DataPointDef {
282    fn from(builder: DataPointBuilder) -> Self {
283        builder.build()
284    }
285}
286
287/// Shorthand function to create a new device config builder.
288pub fn device(id: impl Into<String>, name: impl Into<String>) -> DeviceConfigBuilder {
289    DeviceConfigBuilder::new(id, name)
290}
291
292/// Shorthand function to create a new data point builder.
293pub fn point(id: impl Into<String>, name: impl Into<String>, data_type: DataType) -> DataPointBuilder {
294    DataPointBuilder::new(id, name, data_type)
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    #[test]
302    fn test_device_config_builder() {
303        let config = DeviceConfigBuilder::new("dev-001", "Test Device")
304            .protocol(Protocol::ModbusTcp)
305            .description("A test device")
306            .metadata("location", "Building A")
307            .add_point(
308                DataPointBuilder::new("temp", "Temperature", DataType::Float32)
309                    .units("°C")
310                    .range(-40.0, 80.0)
311                    .build(),
312            )
313            .build()
314            .unwrap();
315
316        assert_eq!(config.id, "dev-001");
317        assert_eq!(config.name, "Test Device");
318        assert_eq!(config.protocol, Protocol::ModbusTcp);
319        assert_eq!(config.points.len(), 1);
320    }
321
322    #[test]
323    fn test_device_config_builder_validation() {
324        // Empty ID should fail
325        let result = DeviceConfigBuilder::new("", "Test")
326            .build();
327        assert!(result.is_err());
328
329        // Empty name should fail
330        let result = DeviceConfigBuilder::new("test", "")
331            .build();
332        assert!(result.is_err());
333
334        // Duplicate point IDs should fail
335        let result = DeviceConfigBuilder::new("dev-001", "Test")
336            .add_point(DataPointBuilder::new("temp", "Temp 1", DataType::Float32).build())
337            .add_point(DataPointBuilder::new("temp", "Temp 2", DataType::Float32).build())
338            .build();
339        assert!(result.is_err());
340    }
341
342    #[test]
343    fn test_data_point_builder() {
344        let point = DataPointBuilder::new("temp", "Temperature", DataType::Float32)
345            .description("Room temperature")
346            .units("°C")
347            .range(-40.0, 80.0)
348            .read_only()
349            .default_value(25.0f64)
350            .build();
351
352        assert_eq!(point.id, "temp");
353        assert_eq!(point.units, Some("°C".to_string()));
354        assert_eq!(point.min_value, Some(-40.0));
355        assert_eq!(point.max_value, Some(80.0));
356        assert_eq!(point.access, crate::types::AccessMode::ReadOnly);
357    }
358
359    #[test]
360    fn test_shorthand_functions() {
361        let config = device("dev-001", "Test")
362            .protocol(Protocol::BacnetIp)
363            .add_point(point("temp", "Temperature", DataType::Float32).build())
364            .build()
365            .unwrap();
366
367        assert_eq!(config.id, "dev-001");
368        assert_eq!(config.protocol, Protocol::BacnetIp);
369    }
370}