Skip to main content

mabi_opcua/
factory.rs

1//! OPC UA device factory implementation.
2//!
3//! Provides factory for creating OPC UA devices from configuration,
4//! implementing the trap-sim-core DeviceFactory trait.
5
6use std::collections::HashMap;
7
8use serde::{Deserialize, Serialize};
9
10use mabi_core::{
11    config::{DeviceConfig, DataPointConfig},
12    device::BoxedDevice,
13    error::{Error, Result},
14    factory::{DeviceFactory, FactoryMetadata, FactoryRegistry, Plugin},
15    protocol::Protocol,
16    types::DataPointDef,
17    value::Value,
18};
19
20use crate::config::OpcUaServerConfig;
21use crate::device::OpcUaDevice;
22use crate::nodes::{AddressSpaceConfig, NodeCacheConfig};
23use crate::services::HistoryStoreConfig;
24use crate::types::{NodeId, Variant};
25
26/// OPC UA specific configuration in device metadata.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct OpcUaDeviceMetadata {
29    /// Server configuration.
30    #[serde(default)]
31    pub server_config: OpcUaServerConfig,
32    /// Address space configuration.
33    #[serde(default)]
34    pub address_space_config: Option<AddressSpaceConfig>,
35    /// Node cache configuration.
36    #[serde(default)]
37    pub cache_config: Option<NodeCacheConfig>,
38    /// History store configuration.
39    #[serde(default)]
40    pub history_config: Option<HistoryStoreConfig>,
41    /// Namespace URI for custom nodes.
42    #[serde(default = "default_namespace")]
43    pub namespace_uri: String,
44    /// Namespace index for custom nodes.
45    #[serde(default = "default_namespace_index")]
46    pub namespace_index: u16,
47}
48
49fn default_namespace() -> String {
50    "urn:trap-simulator:opcua".to_string()
51}
52
53fn default_namespace_index() -> u16 {
54    2
55}
56
57impl Default for OpcUaDeviceMetadata {
58    fn default() -> Self {
59        Self {
60            server_config: OpcUaServerConfig::default(),
61            address_space_config: None,
62            cache_config: None,
63            history_config: None,
64            namespace_uri: default_namespace(),
65            namespace_index: default_namespace_index(),
66        }
67    }
68}
69
70/// Factory for creating OPC UA devices.
71pub struct OpcUaDeviceFactory {
72    /// Default server configuration.
73    default_config: OpcUaServerConfig,
74}
75
76impl OpcUaDeviceFactory {
77    /// Create a new OPC UA device factory.
78    pub fn new() -> Self {
79        Self {
80            default_config: OpcUaServerConfig::default(),
81        }
82    }
83
84    /// Create with custom default configuration.
85    pub fn with_config(config: OpcUaServerConfig) -> Self {
86        Self {
87            default_config: config,
88        }
89    }
90
91    /// Parse OPC UA metadata from device config.
92    fn parse_metadata(&self, config: &DeviceConfig) -> OpcUaDeviceMetadata {
93        if let Some(meta) = config.metadata.get("opcua") {
94            // metadata is HashMap<String, String>, so we need to parse the value
95            serde_json::from_str(meta).unwrap_or_default()
96        } else {
97            OpcUaDeviceMetadata::default()
98        }
99    }
100
101    /// Convert DataPointConfig to Variant.
102    fn point_to_variant(&self, config: &DataPointConfig) -> Variant {
103        // Convert string data type to Variant
104        match config.data_type.to_lowercase().as_str() {
105            "bool" | "boolean" => Variant::Boolean(false),
106            "i8" | "int8" | "sbyte" => Variant::SByte(0),
107            "u8" | "uint8" | "byte" => Variant::Byte(0),
108            "i16" | "int16" => Variant::Int16(0),
109            "u16" | "uint16" => Variant::UInt16(0),
110            "i32" | "int32" => Variant::Int32(0),
111            "u32" | "uint32" => Variant::UInt32(0),
112            "i64" | "int64" => Variant::Int64(0),
113            "u64" | "uint64" => Variant::UInt64(0),
114            "f32" | "float" | "float32" => Variant::Float(0.0),
115            "f64" | "double" | "float64" => Variant::Double(0.0),
116            "string" => Variant::String(String::new()),
117            "datetime" => Variant::DateTime(chrono::Utc::now()),
118            "bytestring" | "bytes" => Variant::ByteString(Vec::new()),
119            _ => Variant::Double(0.0), // Default to double for unknown types
120        }
121    }
122
123    /// Convert DataPointConfig to DataPointDef.
124    fn config_to_def(&self, config: &DataPointConfig) -> DataPointDef {
125        use mabi_core::types::{AccessMode, Address, DataType};
126
127        let data_type = match config.data_type.to_lowercase().as_str() {
128            "bool" | "boolean" => DataType::Bool,
129            "i8" | "int8" | "sbyte" => DataType::Int8,
130            "u8" | "uint8" | "byte" => DataType::UInt8,
131            "i16" | "int16" => DataType::Int16,
132            "u16" | "uint16" => DataType::UInt16,
133            "i32" | "int32" => DataType::Int32,
134            "u32" | "uint32" => DataType::UInt32,
135            "i64" | "int64" => DataType::Int64,
136            "u64" | "uint64" => DataType::UInt64,
137            "f32" | "float" | "float32" => DataType::Float32,
138            "f64" | "double" | "float64" => DataType::Float64,
139            "string" => DataType::String,
140            "datetime" => DataType::DateTime,
141            "bytestring" | "bytes" => DataType::ByteString,
142            _ => DataType::Float64,
143        };
144
145        let access = match config.access.to_lowercase().as_str() {
146            "r" | "ro" | "readonly" => AccessMode::ReadOnly,
147            "w" | "wo" | "writeonly" => AccessMode::WriteOnly,
148            _ => AccessMode::ReadWrite,
149        };
150
151        let mut def = DataPointDef::new(&config.id, &config.name, data_type)
152            .with_access(access);
153
154        if let Some(unit) = &config.units {
155            def = def.with_units(unit);
156        }
157        if let (Some(min), Some(max)) = (config.min, config.max) {
158            def = def.with_range(min, max);
159        }
160        if let Some(addr) = &config.address {
161            def = def.with_address(Address::OpcUa { node_id: addr.clone() });
162        }
163
164        def
165    }
166
167    /// Convert Variant to Value.
168    fn variant_to_value(&self, variant: &Variant) -> Value {
169        variant.clone().into()
170    }
171}
172
173impl Default for OpcUaDeviceFactory {
174    fn default() -> Self {
175        Self::new()
176    }
177}
178
179impl DeviceFactory for OpcUaDeviceFactory {
180    fn protocol(&self) -> Protocol {
181        Protocol::OpcUa
182    }
183
184    fn create(&self, config: DeviceConfig) -> Result<BoxedDevice> {
185        let _metadata = self.parse_metadata(&config);
186
187        // Create device
188        let mut device = OpcUaDevice::new(&config.id, &config.name);
189
190        // Add nodes/variables from point configurations
191        for point_config in &config.points {
192            let initial_variant = self.point_to_variant(point_config);
193            let initial_value = self.variant_to_value(&initial_variant);
194            let point_def = self.config_to_def(point_config);
195            device.add_node(point_def, initial_value);
196        }
197
198        Ok(Box::new(device))
199    }
200
201    fn validate(&self, config: &DeviceConfig) -> Result<()> {
202        if config.id.is_empty() {
203            return Err(Error::Config("Device ID cannot be empty".into()));
204        }
205        if config.name.is_empty() {
206            return Err(Error::Config("Device name cannot be empty".into()));
207        }
208        if config.protocol != Protocol::OpcUa {
209            return Err(Error::Config(format!(
210                "Protocol mismatch: expected OpcUa, got {:?}",
211                config.protocol
212            )));
213        }
214
215        // Validate OPC UA specific metadata if present
216        if let Some(meta_str) = config.metadata.get("opcua") {
217            let _: OpcUaDeviceMetadata = serde_json::from_str(meta_str)
218                .map_err(|e| Error::Config(format!("Invalid OPC UA metadata: {}", e)))?;
219        }
220
221        // Validate point definitions
222        for (i, point) in config.points.iter().enumerate() {
223            if point.id.is_empty() {
224                return Err(Error::Config(format!("Point {} has empty ID", i)));
225            }
226        }
227
228        Ok(())
229    }
230
231    fn metadata(&self) -> FactoryMetadata {
232        FactoryMetadata {
233            protocol: Protocol::OpcUa,
234            version: env!("CARGO_PKG_VERSION").to_string(),
235            description: "OPC UA server simulator factory".to_string(),
236            capabilities: vec![
237                "subscription".to_string(),
238                "history".to_string(),
239                "browse".to_string(),
240                "read".to_string(),
241                "write".to_string(),
242                "monitored_items".to_string(),
243                "sessions".to_string(),
244            ],
245        }
246    }
247}
248
249/// Builder for OPC UA devices with full configuration.
250pub struct OpcUaDeviceBuilder {
251    id: String,
252    name: String,
253    description: Option<String>,
254    server_config: OpcUaServerConfig,
255    address_space_config: AddressSpaceConfig,
256    cache_config: Option<NodeCacheConfig>,
257    history_config: Option<HistoryStoreConfig>,
258    variables: Vec<VariableSpec>,
259    namespace_uri: String,
260}
261
262/// Specification for a variable to create.
263#[derive(Debug, Clone)]
264pub struct VariableSpec {
265    /// Variable ID within the device.
266    pub id: String,
267    /// Display name.
268    pub name: String,
269    /// Node ID.
270    pub node_id: NodeId,
271    /// Initial value.
272    pub initial_value: Variant,
273    /// Is writable.
274    pub writable: bool,
275    /// Engineering unit (optional).
276    pub unit: Option<String>,
277}
278
279impl OpcUaDeviceBuilder {
280    /// Create a new builder.
281    pub fn new(id: impl Into<String>, name: impl Into<String>) -> Self {
282        Self {
283            id: id.into(),
284            name: name.into(),
285            description: None,
286            server_config: OpcUaServerConfig::default(),
287            address_space_config: AddressSpaceConfig::default(),
288            cache_config: None,
289            history_config: None,
290            variables: Vec::new(),
291            namespace_uri: "urn:trap-simulator:opcua".to_string(),
292        }
293    }
294
295    /// Set description.
296    pub fn description(mut self, desc: impl Into<String>) -> Self {
297        self.description = Some(desc.into());
298        self
299    }
300
301    /// Set server configuration.
302    pub fn server_config(mut self, config: OpcUaServerConfig) -> Self {
303        self.server_config = config;
304        self
305    }
306
307    /// Set address space configuration.
308    pub fn address_space_config(mut self, config: AddressSpaceConfig) -> Self {
309        self.address_space_config = config;
310        self
311    }
312
313    /// Enable node cache with configuration.
314    pub fn with_cache(mut self, config: NodeCacheConfig) -> Self {
315        self.cache_config = Some(config);
316        self
317    }
318
319    /// Enable history storage with configuration.
320    pub fn with_history(mut self, config: HistoryStoreConfig) -> Self {
321        self.history_config = Some(config);
322        self
323    }
324
325    /// Set namespace URI.
326    pub fn namespace_uri(mut self, uri: impl Into<String>) -> Self {
327        self.namespace_uri = uri.into();
328        self
329    }
330
331    /// Add a variable.
332    pub fn add_variable(mut self, spec: VariableSpec) -> Self {
333        self.variables.push(spec);
334        self
335    }
336
337    /// Add an analog variable.
338    pub fn add_analog_variable(
339        mut self,
340        id: impl Into<String>,
341        name: impl Into<String>,
342        node_id: NodeId,
343        initial_value: f64,
344    ) -> Self {
345        self.variables.push(VariableSpec {
346            id: id.into(),
347            name: name.into(),
348            node_id,
349            initial_value: Variant::Double(initial_value),
350            writable: true,
351            unit: None,
352        });
353        self
354    }
355
356    /// Add a discrete variable.
357    pub fn add_discrete_variable(
358        mut self,
359        id: impl Into<String>,
360        name: impl Into<String>,
361        node_id: NodeId,
362        initial_state: u32,
363    ) -> Self {
364        self.variables.push(VariableSpec {
365            id: id.into(),
366            name: name.into(),
367            node_id,
368            initial_value: Variant::UInt32(initial_state),
369            writable: true,
370            unit: None,
371        });
372        self
373    }
374
375    /// Add a boolean variable.
376    pub fn add_boolean_variable(
377        mut self,
378        id: impl Into<String>,
379        name: impl Into<String>,
380        node_id: NodeId,
381        initial_value: bool,
382    ) -> Self {
383        self.variables.push(VariableSpec {
384            id: id.into(),
385            name: name.into(),
386            node_id,
387            initial_value: Variant::Boolean(initial_value),
388            writable: true,
389            unit: None,
390        });
391        self
392    }
393
394    /// Add a string variable.
395    pub fn add_string_variable(
396        mut self,
397        id: impl Into<String>,
398        name: impl Into<String>,
399        node_id: NodeId,
400        initial_value: impl Into<String>,
401    ) -> Self {
402        self.variables.push(VariableSpec {
403            id: id.into(),
404            name: name.into(),
405            node_id,
406            initial_value: Variant::String(initial_value.into()),
407            writable: true,
408            unit: None,
409        });
410        self
411    }
412
413    /// Build the device.
414    pub fn build(self) -> OpcUaDevice {
415        let mut device = OpcUaDevice::new(&self.id, &self.name);
416
417        // Add variables
418        for var_spec in self.variables {
419            use mabi_core::types::AccessMode;
420
421            let mut point_def = DataPointDef::new(
422                var_spec.id.clone(),
423                var_spec.name.clone(),
424                Self::variant_to_data_type(&var_spec.initial_value),
425            );
426
427            if var_spec.writable {
428                point_def = point_def.with_access(AccessMode::ReadWrite);
429            } else {
430                point_def = point_def.with_access(AccessMode::ReadOnly);
431            }
432
433            if let Some(unit) = var_spec.unit {
434                point_def = point_def.with_units(unit);
435            }
436
437            let value = Value::from(var_spec.initial_value);
438            device.add_node(point_def, value);
439        }
440
441        device
442    }
443
444    /// Convert Variant to DataType.
445    fn variant_to_data_type(variant: &Variant) -> mabi_core::types::DataType {
446        use mabi_core::types::DataType;
447
448        match variant {
449            Variant::Boolean(_) => DataType::Bool,
450            Variant::SByte(_) => DataType::Int8,
451            Variant::Byte(_) => DataType::UInt8,
452            Variant::Int16(_) => DataType::Int16,
453            Variant::UInt16(_) => DataType::UInt16,
454            Variant::Int32(_) => DataType::Int32,
455            Variant::UInt32(_) => DataType::UInt32,
456            Variant::Int64(_) => DataType::Int64,
457            Variant::UInt64(_) => DataType::UInt64,
458            Variant::Float(_) => DataType::Float32,
459            Variant::Double(_) => DataType::Float64,
460            Variant::String(_) | Variant::Guid(_) => DataType::String,
461            Variant::DateTime(_) => DataType::DateTime,
462            Variant::ByteString(_) => DataType::ByteString,
463            _ => DataType::Float64, // Default
464        }
465    }
466}
467
468/// OPC UA plugin for registering with the plugin system.
469pub struct OpcUaPlugin {
470    name: String,
471    factory_config: Option<OpcUaServerConfig>,
472}
473
474impl OpcUaPlugin {
475    /// Create a new OPC UA plugin.
476    pub fn new() -> Self {
477        Self {
478            name: "opcua".to_string(),
479            factory_config: None,
480        }
481    }
482
483    /// Create with custom name.
484    pub fn with_name(name: impl Into<String>) -> Self {
485        Self {
486            name: name.into(),
487            factory_config: None,
488        }
489    }
490
491    /// Set default factory configuration.
492    pub fn with_factory_config(mut self, config: OpcUaServerConfig) -> Self {
493        self.factory_config = Some(config);
494        self
495    }
496}
497
498impl Default for OpcUaPlugin {
499    fn default() -> Self {
500        Self::new()
501    }
502}
503
504impl Plugin for OpcUaPlugin {
505    fn name(&self) -> &str {
506        &self.name
507    }
508
509    fn version(&self) -> &str {
510        env!("CARGO_PKG_VERSION")
511    }
512
513    fn description(&self) -> &str {
514        "OPC UA server simulator plugin providing OPC UA device support"
515    }
516
517    fn register_factories(&self, registry: &FactoryRegistry) -> Result<()> {
518        let factory = if let Some(config) = &self.factory_config {
519            OpcUaDeviceFactory::with_config(config.clone())
520        } else {
521            OpcUaDeviceFactory::new()
522        };
523
524        registry.register(factory)
525    }
526}
527
528/// Helper to create a device configuration for OPC UA.
529pub fn create_opcua_config(
530    id: impl Into<String>,
531    name: impl Into<String>,
532    points: Vec<DataPointConfig>,
533) -> DeviceConfig {
534    DeviceConfig {
535        id: id.into(),
536        name: name.into(),
537        description: String::new(),
538        protocol: Protocol::OpcUa,
539        address: None,
540        points,
541        metadata: HashMap::new(),
542    }
543}
544
545/// Helper to create a device configuration with OPC UA metadata.
546pub fn create_opcua_config_with_metadata(
547    id: impl Into<String>,
548    name: impl Into<String>,
549    points: Vec<DataPointConfig>,
550    metadata: OpcUaDeviceMetadata,
551) -> DeviceConfig {
552    let mut meta_map = HashMap::new();
553    if let Ok(value) = serde_json::to_string(&metadata) {
554        meta_map.insert("opcua".to_string(), value);
555    }
556
557    DeviceConfig {
558        id: id.into(),
559        name: name.into(),
560        description: String::new(),
561        protocol: Protocol::OpcUa,
562        address: None,
563        points,
564        metadata: meta_map,
565    }
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571    use mabi_core::device::Device;
572
573    fn create_test_point(id: &str, name: &str, data_type: &str) -> DataPointConfig {
574        DataPointConfig {
575            id: id.to_string(),
576            name: name.to_string(),
577            data_type: data_type.to_string(),
578            access: "rw".to_string(),
579            address: None,
580            initial_value: None,
581            units: None,
582            min: None,
583            max: None,
584        }
585    }
586
587    #[test]
588    fn test_factory_protocol() {
589        let factory = OpcUaDeviceFactory::new();
590        assert_eq!(factory.protocol(), Protocol::OpcUa);
591    }
592
593    #[test]
594    fn test_factory_create_device() {
595        let factory = OpcUaDeviceFactory::new();
596
597        let mut point = create_test_point("temp", "Temperature", "f64");
598        point.units = Some("°C".to_string());
599
600        let config = create_opcua_config("server-1", "OPC UA Server 1", vec![point]);
601
602        let device = factory.create(config).unwrap();
603        assert_eq!(device.id(), "server-1");
604        assert_eq!(device.name(), "OPC UA Server 1");
605    }
606
607    #[test]
608    fn test_factory_validation() {
609        let factory = OpcUaDeviceFactory::new();
610
611        // Empty ID should fail
612        let config = DeviceConfig {
613            id: String::new(),
614            name: "Test".to_string(),
615            description: String::new(),
616            protocol: Protocol::OpcUa,
617            address: None,
618            points: vec![],
619            metadata: HashMap::new(),
620        };
621        assert!(factory.validate(&config).is_err());
622
623        // Wrong protocol should fail
624        let config = DeviceConfig {
625            id: "test".to_string(),
626            name: "Test".to_string(),
627            description: String::new(),
628            protocol: Protocol::ModbusTcp,
629            address: None,
630            points: vec![],
631            metadata: HashMap::new(),
632        };
633        assert!(factory.validate(&config).is_err());
634    }
635
636    #[test]
637    fn test_device_builder() {
638        let device = OpcUaDeviceBuilder::new("server-1", "Test Server")
639            .description("A test OPC UA server")
640            .add_analog_variable(
641                "temp",
642                "Temperature",
643                NodeId::numeric(2, 1001),
644                25.0,
645            )
646            .add_boolean_variable(
647                "status",
648                "Status",
649                NodeId::numeric(2, 1002),
650                true,
651            )
652            .build();
653
654        assert_eq!(device.info().id, "server-1");
655        assert!(device.point_definition("temp").is_some());
656        assert!(device.point_definition("status").is_some());
657    }
658
659    #[test]
660    fn test_plugin() {
661        let registry = FactoryRegistry::new();
662        let plugin = OpcUaPlugin::new();
663
664        assert_eq!(plugin.name(), "opcua");
665        plugin.register_factories(&registry).unwrap();
666
667        assert!(registry.has(Protocol::OpcUa));
668    }
669
670    #[test]
671    fn test_factory_metadata() {
672        let factory = OpcUaDeviceFactory::new();
673        let metadata = factory.metadata();
674
675        assert_eq!(metadata.protocol, Protocol::OpcUa);
676        assert!(metadata.capabilities.contains(&"subscription".to_string()));
677        assert!(metadata.capabilities.contains(&"history".to_string()));
678    }
679}