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