1mod cache;
4mod codegen;
5
6use std::collections::{BTreeMap, BTreeSet};
7use std::fs;
8use std::path::{Path, PathBuf};
9
10use serde::{Deserialize, Serialize};
11
12use mabi_core::tags::Tags;
13use mabi_core::types::{AccessMode, Address, DataPointDef, DataType};
14use mabi_core::value::Value;
15use mabi_runtime::{ProtocolLaunchSpec, RuntimeExtensions};
16
17pub use self::cache::CompilationCacheReport;
18use self::cache::{
19 build_compilation_cache_key, build_import_cache_key, ImportCacheCounters, ModelingCache,
20};
21use self::codegen::{build_generated_type_catalog, render_generated_rust_module};
22use crate::config::{
23 MessageSecurityMode, OpcUaServerConfig, SecurityPolicy, TransportConnectionMode,
24 TransportProtocol,
25};
26use crate::error::{OpcUaError, OpcUaResult};
27use crate::nodes::base::{LocalizedText, QualifiedName};
28use crate::nodes::classes::{
29 DataTypeNode, MethodNode, ObjectNode, ObjectTypeNode, ReferenceTypeNode, VariableNode,
30 VariableTypeNode, ViewNode,
31};
32use crate::nodes::{AddressSpace, Reference, ReferenceDirection, ReferenceTypeId};
33use crate::sdk::subscription::SubscriptionDurabilityConfig;
34use crate::security::{
35 DeprecatedPolicyHandling, RoleMappingRule, SecurityAuditSinkConfig, SecurityManagerConfig,
36};
37use crate::types::{AccessLevel, NodeId, NodeIdType, Variant};
38
39const STANDARD_NAMESPACE_URI: &str = "http://opcfoundation.org/UA/";
40const DEFAULT_NAMESPACE_URI: &str = "urn:mabinogion:opcua:simulator";
41const DEFAULT_SERVER_NAME: &str = "Mabinogion OPC UA Simulator";
42const DEFAULT_ENDPOINT_PATH: &str = "/";
43
44#[derive(Debug, Clone, Default, Serialize, Deserialize)]
46pub struct OpcUaSimulatorConfig {
47 #[serde(default)]
48 pub defaults: SimulatorDefaults,
49 #[serde(default)]
50 pub transports: BTreeMap<String, TransportDefinition>,
51 #[serde(default)]
52 pub security_profiles: BTreeMap<String, SecurityProfileDefinition>,
53 #[serde(default)]
54 pub nodesets: BTreeMap<String, NodeSetSource>,
55 #[serde(default)]
56 pub companion_packs: BTreeMap<String, CompanionPackDefinition>,
57 #[serde(default)]
58 pub models: BTreeMap<String, ModelDefinition>,
59 #[serde(default)]
60 pub devices: BTreeMap<String, DeviceDefinition>,
61 #[serde(default)]
62 pub sessions: BTreeMap<String, SessionDefinition>,
63 #[serde(default)]
64 pub presets: BTreeMap<String, PresetDefinition>,
65 #[serde(default)]
66 pub generated_types: GeneratedTypesConfig,
67}
68
69impl OpcUaSimulatorConfig {
70 pub fn from_path(path: &Path) -> OpcUaResult<Self> {
72 let content = fs::read_to_string(path)?;
73 let extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or("");
74 Self::from_str_with_format(&content, extension, Some(path))
75 }
76
77 pub fn from_str_with_format(
79 content: &str,
80 format: &str,
81 base_path: Option<&Path>,
82 ) -> OpcUaResult<Self> {
83 let parsed: Self = match format {
84 "yaml" | "yml" => serde_yaml::from_str(content)
85 .map_err(|error| OpcUaError::Config(format!("invalid YAML config: {}", error)))?,
86 "json" => serde_json::from_str(content)
87 .map_err(|error| OpcUaError::Config(format!("invalid JSON config: {}", error)))?,
88 "toml" => toml::from_str(content)
89 .map_err(|error| OpcUaError::Config(format!("invalid TOML config: {}", error)))?,
90 other => {
91 return Err(OpcUaError::Config(format!(
92 "unsupported config format: {}",
93 other
94 )))
95 }
96 };
97 parsed.validate(base_path)?;
98 Ok(parsed)
99 }
100
101 pub fn validate(&self, base_path: Option<&Path>) -> OpcUaResult<()> {
103 if self.sessions.is_empty() {
104 return Err(OpcUaError::Config(
105 "simulator config must define at least one session".into(),
106 ));
107 }
108
109 for (name, session) in &self.sessions {
110 if !self.transports.contains_key(&session.transport) {
111 return Err(OpcUaError::Config(format!(
112 "session '{}' references unknown transport '{}'",
113 name, session.transport
114 )));
115 }
116 if session.models.is_empty() && session.devices.is_empty() && session.preset.is_none() {
117 return Err(OpcUaError::Config(format!(
118 "session '{}' must reference at least one model, device, or preset",
119 name
120 )));
121 }
122 for model in &session.models {
123 if !self.models.contains_key(model) {
124 return Err(OpcUaError::Config(format!(
125 "session '{}' references unknown model '{}'",
126 name, model
127 )));
128 }
129 }
130 for device in &session.devices {
131 if !self.devices.contains_key(device) {
132 return Err(OpcUaError::Config(format!(
133 "session '{}' references unknown device '{}'",
134 name, device
135 )));
136 }
137 }
138 if let Some(preset) = &session.preset {
139 if !self.presets.contains_key(preset) {
140 return Err(OpcUaError::Config(format!(
141 "session '{}' references unknown preset '{}'",
142 name, preset
143 )));
144 }
145 }
146 }
147
148 for (name, transport) in &self.transports {
149 if let Some(profile_name) = &transport.security_profile {
150 resolve_security_profile_definition(self, profile_name).ok_or_else(|| {
151 OpcUaError::Config(format!(
152 "transport '{}' references unknown security profile '{}'",
153 name, profile_name
154 ))
155 })?;
156 }
157 if transport.protocol == TransportProtocol::Https
158 && (transport.certificate_path.is_none() || transport.private_key_path.is_none())
159 {
160 return Err(OpcUaError::Config(format!(
161 "https transport '{}' requires certificate_path and private_key_path",
162 name
163 )));
164 }
165 if transport.connection_mode == TransportConnectionMode::ReverseConnect {
166 if transport.protocol != TransportProtocol::OpcTcp {
167 return Err(OpcUaError::Config(format!(
168 "transport '{}' only supports reverse_connect with opc.tcp",
169 name
170 )));
171 }
172 if transport.reverse_connect_target.is_none() {
173 return Err(OpcUaError::Config(format!(
174 "transport '{}' requires reverse_connect_target when connection_mode is reverse_connect",
175 name
176 )));
177 }
178 if transport.retry_interval_ms == 0 {
179 return Err(OpcUaError::Config(format!(
180 "transport '{}' requires retry_interval_ms > 0 for reverse_connect",
181 name
182 )));
183 }
184 } else if transport.reverse_connect_target.is_some() {
185 return Err(OpcUaError::Config(format!(
186 "transport '{}' sets reverse_connect_target but connection_mode is not reverse_connect",
187 name
188 )));
189 }
190 }
191
192 for (name, model) in &self.models {
193 for nodeset in &model.nodesets {
194 let source = self.nodesets.get(nodeset).ok_or_else(|| {
195 OpcUaError::Config(format!(
196 "model '{}' references unknown nodeset '{}'",
197 name, nodeset
198 ))
199 })?;
200 validate_nodeset_source(nodeset, source, base_path)?;
201 }
202 for companion in &model.companions {
203 if !companion.name.is_empty() && !self.companion_packs.contains_key(&companion.name)
204 {
205 return Err(OpcUaError::Config(format!(
206 "model '{}' references unknown companion pack '{}'",
207 name, companion.name
208 )));
209 }
210 }
211 let mut overlay_ids = BTreeSet::new();
212 for overlay in &model.overlays {
213 let overlay_node_id = match overlay {
214 OverlayNodeDefinition::Folder { node_id, .. }
215 | OverlayNodeDefinition::Object { node_id, .. }
216 | OverlayNodeDefinition::Variable { node_id, .. } => node_id,
217 };
218 if !overlay_ids.insert(overlay_node_id.clone()) {
219 return Err(OpcUaError::Config(format!(
220 "model '{}' contains duplicate overlay node '{}'",
221 name, overlay_node_id
222 )));
223 }
224 }
225 }
226
227 for (name, device) in &self.devices {
228 if !self.models.contains_key(&device.model) {
229 return Err(OpcUaError::Config(format!(
230 "device '{}' references unknown model '{}'",
231 name, device.model
232 )));
233 }
234 }
235
236 for name in self.sessions.keys() {
237 self.compile_session(name, base_path)?;
238 }
239
240 Ok(())
241 }
242
243 pub fn compile_session(
245 &self,
246 name: &str,
247 base_path: Option<&Path>,
248 ) -> OpcUaResult<CompiledOpcUaSession> {
249 self.compile_session_with_report(name, base_path)
250 .map(|(compiled, _)| compiled)
251 }
252
253 pub fn compile_session_with_report(
255 &self,
256 name: &str,
257 base_path: Option<&Path>,
258 ) -> OpcUaResult<(CompiledOpcUaSession, CompilationCacheReport)> {
259 let cache = ModelingCache::new();
260 let cache_key = build_compilation_cache_key(self, name, base_path)?;
261 if let Some(compiled) = cache.load_compiled_session(&cache_key) {
262 return Ok((
263 compiled,
264 CompilationCacheReport {
265 compilation_hit: true,
266 import_hits: 0,
267 import_misses: 0,
268 cache_dir: cache.root_display(),
269 },
270 ));
271 }
272
273 let session = self
274 .sessions
275 .get(name)
276 .ok_or_else(|| OpcUaError::Config(format!("unknown session '{}'", name)))?;
277 let transport = self.transports.get(&session.transport).ok_or_else(|| {
278 OpcUaError::Config(format!(
279 "session '{}' references unknown transport '{}'",
280 name, session.transport
281 ))
282 })?;
283 let resolved_security_profile = resolve_security_profile_definition(
284 self,
285 transport
286 .security_profile
287 .as_ref()
288 .or(self.defaults.security_profile.as_ref())
289 .map(String::as_str)
290 .unwrap_or("None"),
291 )
292 .ok_or_else(|| {
293 OpcUaError::Config(format!(
294 "session '{}' references unknown security profile",
295 name
296 ))
297 })?;
298
299 let mut synthetic_models = BTreeMap::new();
300 let mut synthetic_devices = BTreeMap::new();
301 if let Some(preset_name) = &session.preset {
302 let preset = self
303 .presets
304 .get(preset_name)
305 .ok_or_else(|| OpcUaError::Config(format!("unknown preset '{}'", preset_name)))?;
306 let (model_name, model, device_name, device) = preset.compile(
307 preset_name,
308 &self.defaults,
309 session.service_name.as_deref().unwrap_or(name),
310 );
311 synthetic_models.insert(model_name.clone(), model);
312 synthetic_devices.insert(device_name.clone(), device);
313 }
314
315 let mut ordered_model_names = Vec::new();
316 let mut seen_models = BTreeSet::new();
317 for model_name in &session.models {
318 if seen_models.insert(model_name.clone()) {
319 ordered_model_names.push(model_name.clone());
320 }
321 }
322 for device_name in &session.devices {
323 let device = self
324 .devices
325 .get(device_name)
326 .ok_or_else(|| OpcUaError::Config(format!("unknown device '{}'", device_name)))?;
327 if seen_models.insert(device.model.clone()) {
328 ordered_model_names.push(device.model.clone());
329 }
330 }
331 for device in synthetic_devices.values() {
332 if seen_models.insert(device.model.clone()) {
333 ordered_model_names.push(device.model.clone());
334 }
335 }
336
337 let mut namespace_table = vec![
338 STANDARD_NAMESPACE_URI.to_string(),
339 self.defaults.namespace_uri.clone(),
340 ];
341 let mut plan_sources = Vec::new();
342 let mut imported_nodesets = Vec::new();
343 let mut import_cache = ImportCacheCounters::default();
344
345 for model_name in &ordered_model_names {
346 let model = self
347 .models
348 .get(model_name)
349 .or_else(|| synthetic_models.get(model_name))
350 .ok_or_else(|| OpcUaError::Config(format!("unknown model '{}'", model_name)))?;
351 if let Some(namespace_uri) = &model.namespace_uri {
352 push_unique(&mut namespace_table, namespace_uri.clone());
353 }
354 for companion in &model.companions {
355 if let Some(namespace_uri) = &companion.namespace_uri {
356 push_unique(&mut namespace_table, namespace_uri.clone());
357 }
358 if let Some(pack) = self.companion_packs.get(&companion.name) {
359 if let Some(namespace_uri) = &pack.namespace_uri {
360 push_unique(&mut namespace_table, namespace_uri.clone());
361 }
362 }
363 }
364 for nodeset_name in &model.nodesets {
365 let source = self.nodesets.get(nodeset_name).ok_or_else(|| {
366 OpcUaError::Config(format!(
367 "model '{}' references unknown nodeset '{}'",
368 model_name, nodeset_name
369 ))
370 })?;
371 let imported =
372 import_nodeset_source_cached(source, base_path, &cache, &mut import_cache)?;
373 for uri in imported.local_namespace_table.iter().skip(1) {
374 push_unique(&mut namespace_table, uri.clone());
375 }
376 imported_nodesets.push((model_name.clone(), nodeset_name.clone(), imported));
377 plan_sources.push(format!("model:{} -> nodeset:{}", model_name, nodeset_name));
378 }
379 for companion in &model.companions {
380 if let Some(pack) = self.companion_packs.get(&companion.name) {
381 for nodeset_name in &pack.nodesets {
382 let source = self.nodesets.get(nodeset_name).ok_or_else(|| {
383 OpcUaError::Config(format!(
384 "companion pack '{}' references unknown nodeset '{}'",
385 companion.name, nodeset_name
386 ))
387 })?;
388 let imported = import_nodeset_source_cached(
389 source,
390 base_path,
391 &cache,
392 &mut import_cache,
393 )?;
394 for uri in imported.local_namespace_table.iter().skip(1) {
395 push_unique(&mut namespace_table, uri.clone());
396 }
397 imported_nodesets.push((
398 model_name.clone(),
399 format!("companion:{}", nodeset_name),
400 imported,
401 ));
402 plan_sources.push(format!(
403 "model:{} -> companion:{} -> nodeset:{}",
404 model_name, companion.name, nodeset_name
405 ));
406 }
407 }
408 }
409 }
410
411 let mut node_map = BTreeMap::<String, GeneratedNodeDefinition>::new();
412 let mut references = Vec::new();
413 let mut methods = Vec::new();
414 let mut events = Vec::new();
415
416 for (model_name, nodeset_name, imported) in &imported_nodesets {
417 let mapping = namespace_mapping(&imported.local_namespace_table, &namespace_table)?;
418 for node in &imported.nodes {
419 let remapped = node.remap_namespaces(&mapping)?;
420 let key = remapped.node_id().to_string();
421 if node_map.insert(key.clone(), remapped).is_some() {
422 return Err(OpcUaError::Config(format!(
423 "imported nodes collide on final NodeId '{}'",
424 key
425 )));
426 }
427 }
428 for reference in &imported.references {
429 references.push(reference.remap_namespaces(&mapping)?);
430 }
431 plan_sources.push(format!("import:{}:{}", model_name, nodeset_name));
432 }
433
434 for model_name in &ordered_model_names {
435 let model = self
436 .models
437 .get(model_name)
438 .or_else(|| synthetic_models.get(model_name))
439 .ok_or_else(|| OpcUaError::Config(format!("unknown model '{}'", model_name)))?;
440
441 for overlay in &model.overlays {
442 let node = overlay.compile()?;
443 let key = node.node_id().to_string();
444 if node_map.insert(key.clone(), node).is_some() {
445 return Err(OpcUaError::Config(format!(
446 "model '{}' overlay collides on final NodeId '{}'",
447 model_name, key
448 )));
449 }
450 }
451 for reference in &model.references {
452 references.push(reference.compile()?);
453 }
454 for method in &model.methods {
455 let (node, structural_reference) = method.compile()?;
456 let key = node.node_id().to_string();
457 if node_map.insert(key.clone(), node).is_some() {
458 return Err(OpcUaError::Config(format!(
459 "model '{}' method collides on final NodeId '{}'",
460 model_name, key
461 )));
462 }
463 if let Some(reference) = structural_reference {
464 references.push(reference);
465 }
466 methods.push(method.clone());
467 }
468 events.extend(model.events.clone());
469 }
470
471 for reference in &references {
472 if !node_map.contains_key(&reference.source_node_id.to_string())
473 && !is_standard_node(&reference.source_node_id)
474 {
475 return Err(OpcUaError::Config(format!(
476 "reference source '{}' does not exist",
477 reference.source_node_id
478 )));
479 }
480 if !node_map.contains_key(&reference.target_node_id.to_string())
481 && !is_standard_node(&reference.target_node_id)
482 {
483 return Err(OpcUaError::Config(format!(
484 "reference target '{}' does not exist",
485 reference.target_node_id
486 )));
487 }
488 }
489
490 let mut compiled_devices = Vec::new();
491 if session.devices.is_empty() && synthetic_devices.is_empty() {
492 for model_name in &ordered_model_names {
493 let model = self
494 .models
495 .get(model_name)
496 .or_else(|| synthetic_models.get(model_name))
497 .ok_or_else(|| OpcUaError::Config(format!("unknown model '{}'", model_name)))?;
498 let auto_device = DeviceDefinition {
499 model: model_name.clone(),
500 node_bindings: Vec::new(),
501 tags: Tags::new().with_tag("model", model_name.clone()),
502 name: Some(model.display_name(model_name)),
503 };
504 compiled_devices.push(compile_device(
505 &format!("device-{}", model_name),
506 &auto_device,
507 &node_map,
508 )?);
509 }
510 } else {
511 for device_name in &session.devices {
512 let device = self.devices.get(device_name).ok_or_else(|| {
513 OpcUaError::Config(format!("unknown device '{}'", device_name))
514 })?;
515 compiled_devices.push(compile_device(device_name, device, &node_map)?);
516 }
517 for (device_name, device) in &synthetic_devices {
518 compiled_devices.push(compile_device(device_name, device, &node_map)?);
519 }
520 }
521
522 let mut point_ids = BTreeSet::new();
523 for device in &compiled_devices {
524 for point in &device.points {
525 if !point_ids.insert(point.point_id.clone()) {
526 return Err(OpcUaError::Config(format!(
527 "compiled session '{}' contains duplicate point id '{}'",
528 name, point.point_id
529 )));
530 }
531 }
532 }
533
534 let catalog = GeneratedNodeCatalog {
535 namespace_table: namespace_table.clone(),
536 namespace_plan: NamespaceCompilationPlan {
537 namespaces: namespace_table.clone(),
538 sources: plan_sources,
539 models: ordered_model_names.clone(),
540 },
541 nodes: node_map.into_values().collect(),
542 references,
543 type_tree_seeds: collect_type_tree_seeds(&namespace_table),
544 methods,
545 events,
546 point_bindings: compiled_devices
547 .iter()
548 .flat_map(|device| device.points.iter().cloned())
549 .collect(),
550 };
551 let generated_types = build_generated_type_catalog(
552 name,
553 &catalog,
554 &self.generated_types,
555 session.service_name.as_deref(),
556 );
557 let security = compile_security_profile(&resolved_security_profile);
558
559 let compiled_launch = OpcUaCompiledLaunchConfig {
560 session_name: name.to_string(),
561 server_config: build_server_config(
562 &self.defaults,
563 session,
564 transport,
565 &security,
566 base_path,
567 ),
568 catalog: catalog.clone(),
569 devices: compiled_devices.clone(),
570 control: session.control.clone(),
571 runtime: session.runtime.clone(),
572 generated_types: generated_types.clone(),
573 security,
574 readiness_timeout_ms: session
575 .readiness_timeout_ms
576 .or(self.defaults.readiness_timeout_ms),
577 };
578
579 let launch = ProtocolLaunchSpec {
580 protocol: "opcua".into(),
581 name: Some(
582 session
583 .service_name
584 .clone()
585 .unwrap_or_else(|| name.to_string()),
586 ),
587 config: serde_json::to_value(&compiled_launch)
588 .map_err(|error| OpcUaError::Config(error.to_string()))?,
589 };
590
591 let compiled = CompiledOpcUaSession {
592 session_name: name.to_string(),
593 launch,
594 namespace_plan: catalog.namespace_plan.clone(),
595 catalog,
596 devices: compiled_devices,
597 control: session.control.clone(),
598 runtime: session.runtime.clone(),
599 generated_types,
600 security: compiled_launch.security.clone(),
601 readiness_timeout_ms: compiled_launch.readiness_timeout_ms,
602 };
603
604 let _ = cache.save_compiled_session(&cache_key, &compiled);
605
606 Ok((
607 compiled,
608 CompilationCacheReport {
609 compilation_hit: false,
610 import_hits: import_cache.hits,
611 import_misses: import_cache.misses,
612 cache_dir: cache.root_display(),
613 },
614 ))
615 }
616
617 pub fn inspect_summary(&self) -> OpcUaConfigSummary {
619 OpcUaConfigSummary {
620 transports: self.transports.keys().cloned().collect(),
621 security_profiles: self.security_profiles.keys().cloned().collect(),
622 nodesets: self.nodesets.keys().cloned().collect(),
623 companion_packs: self.companion_packs.keys().cloned().collect(),
624 models: self.models.keys().cloned().collect(),
625 devices: self.devices.keys().cloned().collect(),
626 sessions: self
627 .sessions
628 .iter()
629 .map(|(name, session)| {
630 let transport_protocol = self
631 .transports
632 .get(&session.transport)
633 .map(|transport| transport.protocol)
634 .unwrap_or_default();
635 let transport_connection_mode = self
636 .transports
637 .get(&session.transport)
638 .map(|transport| transport.connection_mode)
639 .unwrap_or_default();
640 OpcUaSessionSummary {
641 name: name.clone(),
642 transport: session.transport.clone(),
643 transport_protocol,
644 transport_connection_mode,
645 models: session.models.clone(),
646 devices: session.devices.clone(),
647 preset: session.preset.clone(),
648 service_name: session.service_name.clone(),
649 }
650 })
651 .collect(),
652 presets: self.presets.keys().cloned().collect(),
653 generated_types_enabled: self.generated_types.enabled,
654 }
655 }
656}
657
658pub fn load_simulator_config(path: &Path) -> OpcUaResult<OpcUaSimulatorConfig> {
660 OpcUaSimulatorConfig::from_path(path)
661}
662
663pub fn compile_session(
665 config: &OpcUaSimulatorConfig,
666 session_name: &str,
667 base_path: Option<&Path>,
668) -> OpcUaResult<CompiledOpcUaSession> {
669 config.compile_session(session_name, base_path)
670}
671
672pub fn compile_session_with_report(
674 config: &OpcUaSimulatorConfig,
675 session_name: &str,
676 base_path: Option<&Path>,
677) -> OpcUaResult<(CompiledOpcUaSession, CompilationCacheReport)> {
678 config.compile_session_with_report(session_name, base_path)
679}
680
681pub fn generate_types(
683 config: &OpcUaSimulatorConfig,
684 session_name: &str,
685 base_path: Option<&Path>,
686) -> OpcUaResult<GeneratedRustModule> {
687 generate_types_with_report(config, session_name, base_path).map(|(generated, _)| generated)
688}
689
690pub fn generate_types_with_report(
692 config: &OpcUaSimulatorConfig,
693 session_name: &str,
694 base_path: Option<&Path>,
695) -> OpcUaResult<(GeneratedRustModule, CompilationCacheReport)> {
696 let (compiled, report) = config.compile_session_with_report(session_name, base_path)?;
697 Ok((
698 render_generated_rust_module(&compiled.generated_types),
699 report,
700 ))
701}
702
703pub fn schema_summary() -> OpcUaSchemaSummary {
705 OpcUaSchemaSummary {
706 kind: "opcua_simulator",
707 formats: vec!["yaml", "json", "toml"],
708 top_level_sections: vec![
709 SchemaSection::new(
710 "defaults",
711 false,
712 "Runtime-wide default namespace and server settings",
713 ),
714 SchemaSection::new("transports", true, "Named OPC UA endpoint definitions"),
715 SchemaSection::new(
716 "security_profiles",
717 false,
718 "Named security policy, audit, and role-mapping presets",
719 ),
720 SchemaSection::new("nodesets", false, "NodeSet2 import sources"),
721 SchemaSection::new(
722 "companion_packs",
723 false,
724 "Named companion-model bundles over NodeSet2 imports",
725 ),
726 SchemaSection::new("models", false, "Address-space composition and overlays"),
727 SchemaSection::new("devices", false, "Runtime-visible point bindings"),
728 SchemaSection::new("sessions", true, "Named runtime sessions"),
729 SchemaSection::new("presets", false, "Legacy convenience generation"),
730 SchemaSection::new(
731 "generated_types",
732 false,
733 "Deterministic Rust wrapper generation settings",
734 ),
735 ],
736 commands: vec![
737 "mabi inspect opcua-schema",
738 "mabi inspect opcua-config <file>",
739 "mabi validate opcua-config <file>",
740 "mabi serve opcua --config <file> --session <name>",
741 "mabi control opcua --config <file> --session <name> ...",
742 "mabi generate opcua-types --config <file> --session <name> --out <dir>",
743 ],
744 notes: vec![
745 "NodeSet2 imports are runtime-loaded and deterministic",
746 "Remote fetch and scripting are intentionally unsupported",
747 "Legacy builder and numeric serve paths compile into ephemeral sessions",
748 ],
749 }
750}
751
752pub fn inspect_summary(config: &OpcUaSimulatorConfig) -> OpcUaConfigSummary {
754 config.inspect_summary()
755}
756
757#[derive(Debug, Clone, Serialize, Deserialize)]
759pub struct SimulatorDefaults {
760 #[serde(default = "default_namespace_uri")]
761 pub namespace_uri: String,
762 #[serde(default)]
763 pub readiness_timeout_ms: Option<u64>,
764 #[serde(default = "default_server_name")]
765 pub server_name: String,
766 #[serde(default)]
767 pub min_publishing_interval_ms: Option<u32>,
768 #[serde(default)]
769 pub security_profile: Option<String>,
770}
771
772impl Default for SimulatorDefaults {
773 fn default() -> Self {
774 Self {
775 namespace_uri: default_namespace_uri(),
776 readiness_timeout_ms: Some(5_000),
777 server_name: default_server_name(),
778 min_publishing_interval_ms: Some(100),
779 security_profile: Some("None".into()),
780 }
781 }
782}
783
784#[derive(Debug, Clone, Serialize, Deserialize)]
786pub struct TransportDefinition {
787 #[serde(default)]
788 pub protocol: TransportProtocol,
789 #[serde(default)]
790 pub connection_mode: TransportConnectionMode,
791 #[serde(default = "default_bind_address")]
792 pub bind: String,
793 #[serde(default = "default_port")]
794 pub port: u16,
795 #[serde(default = "default_endpoint_path")]
796 pub endpoint_path: String,
797 #[serde(default)]
798 pub reverse_connect_target: Option<String>,
799 #[serde(default = "default_retry_interval_ms")]
800 pub retry_interval_ms: u64,
801 #[serde(default)]
802 pub security_profile: Option<String>,
803 #[serde(default)]
804 pub server_name: Option<String>,
805 #[serde(default)]
806 pub certificate_path: Option<PathBuf>,
807 #[serde(default)]
808 pub private_key_path: Option<PathBuf>,
809}
810
811impl Default for TransportDefinition {
812 fn default() -> Self {
813 Self {
814 protocol: TransportProtocol::OpcTcp,
815 connection_mode: TransportConnectionMode::Listener,
816 bind: default_bind_address(),
817 port: default_port(),
818 endpoint_path: default_endpoint_path(),
819 reverse_connect_target: None,
820 retry_interval_ms: default_retry_interval_ms(),
821 security_profile: Some("None".into()),
822 server_name: None,
823 certificate_path: None,
824 private_key_path: None,
825 }
826 }
827}
828
829#[derive(Debug, Clone, Serialize, Deserialize)]
831pub struct SecurityProfileDefinition {
832 #[serde(default = "default_security_profile_policy")]
833 pub policy: String,
834 #[serde(default)]
835 pub mode: Option<MessageSecurityMode>,
836 #[serde(default)]
837 pub deprecated_policies: DeprecatedPolicyHandling,
838 #[serde(default)]
839 pub audit_sink: SecurityAuditSinkConfig,
840 #[serde(default)]
841 pub role_rules: Vec<RoleMappingRule>,
842 #[serde(default = "default_true")]
843 pub allow_trust_reload: bool,
844 #[serde(default = "default_true")]
845 pub allow_certificate_rotation: bool,
846}
847
848impl Default for SecurityProfileDefinition {
849 fn default() -> Self {
850 Self {
851 policy: default_security_profile_policy(),
852 mode: Some(MessageSecurityMode::None),
853 deprecated_policies: DeprecatedPolicyHandling::Allow,
854 audit_sink: SecurityAuditSinkConfig::default(),
855 role_rules: Vec::new(),
856 allow_trust_reload: true,
857 allow_certificate_rotation: true,
858 }
859 }
860}
861
862#[derive(Debug, Clone, Default, Serialize, Deserialize)]
864pub struct CompanionPackDefinition {
865 #[serde(default)]
866 pub namespace_uri: Option<String>,
867 #[serde(default)]
868 pub nodesets: Vec<String>,
869}
870
871#[derive(Debug, Clone, Serialize, Deserialize)]
873pub struct GeneratedTypesConfig {
874 #[serde(default = "default_true")]
875 pub enabled: bool,
876 #[serde(default)]
877 pub module_name: Option<String>,
878}
879
880impl Default for GeneratedTypesConfig {
881 fn default() -> Self {
882 Self {
883 enabled: true,
884 module_name: None,
885 }
886 }
887}
888
889#[derive(Debug, Clone, Serialize, Deserialize)]
891#[serde(tag = "kind", rename_all = "snake_case")]
892pub enum NodeSetSource {
893 File {
894 path: PathBuf,
895 #[serde(default)]
896 namespace_uri_override: Option<String>,
897 },
898 Embedded {
899 alias: String,
900 #[serde(default)]
901 namespace_uri_override: Option<String>,
902 },
903}
904
905#[derive(Debug, Clone, Default, Serialize, Deserialize)]
907pub struct CompanionModelRef {
908 pub name: String,
909 #[serde(default)]
910 pub namespace_uri: Option<String>,
911}
912
913#[derive(Debug, Clone, Default, Serialize, Deserialize)]
915pub struct ModelDefinition {
916 #[serde(default)]
917 pub nodesets: Vec<String>,
918 #[serde(default)]
919 pub namespace_uri: Option<String>,
920 #[serde(default)]
921 pub companions: Vec<CompanionModelRef>,
922 #[serde(default)]
923 pub overlays: Vec<OverlayNodeDefinition>,
924 #[serde(default)]
925 pub references: Vec<ReferenceDefinition>,
926 #[serde(default)]
927 pub methods: Vec<MethodDefinition>,
928 #[serde(default)]
929 pub events: Vec<EventDefinition>,
930}
931
932impl ModelDefinition {
933 fn display_name(&self, fallback: &str) -> String {
934 fallback.replace('-', " ")
935 }
936}
937
938#[derive(Debug, Clone, Default, Serialize, Deserialize)]
940pub struct DeviceDefinition {
941 pub model: String,
942 #[serde(default)]
943 pub node_bindings: Vec<NodeBindingDefinition>,
944 #[serde(default, skip_serializing_if = "Tags::is_empty")]
945 pub tags: Tags,
946 #[serde(default)]
947 pub name: Option<String>,
948}
949
950#[derive(Debug, Clone, Serialize, Deserialize)]
952pub struct NodeBindingDefinition {
953 pub point_id: String,
954 pub node_id: String,
955 #[serde(default)]
956 pub label: Option<String>,
957 #[serde(default)]
958 pub writable: Option<bool>,
959 #[serde(default)]
960 pub historizing: Option<bool>,
961 #[serde(default)]
962 pub sampling_interval_ms: Option<u32>,
963 #[serde(default)]
964 pub seed: Option<Value>,
965 #[serde(default, skip_serializing_if = "Tags::is_empty")]
966 pub tags: Tags,
967}
968
969#[derive(Debug, Clone, Serialize, Deserialize)]
971pub struct SessionControlConfig {
972 #[serde(default = "default_true")]
973 pub allow_raw_node_access: bool,
974 #[serde(default)]
975 pub clear_persisted_subscriptions_on_reset: bool,
976}
977
978impl Default for SessionControlConfig {
979 fn default() -> Self {
980 Self {
981 allow_raw_node_access: true,
982 clear_persisted_subscriptions_on_reset: false,
983 }
984 }
985}
986
987#[derive(Debug, Clone, Default, Serialize, Deserialize)]
989pub struct SessionRuntimeConfig {
990 #[serde(default)]
991 pub durability: SubscriptionDurabilityConfig,
992}
993
994#[derive(Debug, Clone, Default, Serialize, Deserialize)]
996pub struct SessionDefinition {
997 pub transport: String,
998 #[serde(default)]
999 pub models: Vec<String>,
1000 #[serde(default)]
1001 pub devices: Vec<String>,
1002 #[serde(default)]
1003 pub preset: Option<String>,
1004 #[serde(default)]
1005 pub service_name: Option<String>,
1006 #[serde(default)]
1007 pub readiness_timeout_ms: Option<u64>,
1008 #[serde(default)]
1009 pub control: SessionControlConfig,
1010 #[serde(default)]
1011 pub runtime: SessionRuntimeConfig,
1012}
1013
1014#[derive(Debug, Clone, Serialize, Deserialize)]
1016pub struct PresetDefinition {
1017 #[serde(default = "default_preset_nodes")]
1018 pub nodes: usize,
1019 #[serde(default = "default_preset_base_node")]
1020 pub base_node_id: u32,
1021 #[serde(default = "default_true")]
1022 pub writable: bool,
1023 #[serde(default)]
1024 pub historizing: bool,
1025 #[serde(default)]
1026 pub folder_name: Option<String>,
1027}
1028
1029impl Default for PresetDefinition {
1030 fn default() -> Self {
1031 Self {
1032 nodes: default_preset_nodes(),
1033 base_node_id: default_preset_base_node(),
1034 writable: true,
1035 historizing: false,
1036 folder_name: None,
1037 }
1038 }
1039}
1040
1041impl PresetDefinition {
1042 fn compile(
1043 &self,
1044 preset_name: &str,
1045 defaults: &SimulatorDefaults,
1046 service_name: &str,
1047 ) -> (String, ModelDefinition, String, DeviceDefinition) {
1048 let folder_id = format!("ns=1;s={}.folder", preset_name);
1049 let folder_name = self
1050 .folder_name
1051 .clone()
1052 .unwrap_or_else(|| format!("{} Model", service_name));
1053 let mut overlays = vec![OverlayNodeDefinition::Folder {
1054 node_id: folder_id.clone(),
1055 browse_name: folder_name.clone(),
1056 display_name: Some(folder_name.clone()),
1057 description: Some("Generated from legacy numeric OPC UA preset".into()),
1058 }];
1059 let mut bindings = Vec::new();
1060 for index in 0..self.nodes {
1061 let node_id = format!("ns=1;i={}", self.base_node_id + index as u32);
1062 let browse_name = format!("Variable_{}", index);
1063 overlays.push(OverlayNodeDefinition::Variable {
1064 node_id: node_id.clone(),
1065 browse_name: browse_name.clone(),
1066 display_name: Some(browse_name.clone()),
1067 description: Some("Generated variable".into()),
1068 data_type: Some("i=11".into()),
1069 value: Some(Value::F64(index as f64 * 0.1)),
1070 writable: self.writable,
1071 historizing: self.historizing,
1072 sampling_interval_ms: defaults.min_publishing_interval_ms,
1073 });
1074 bindings.push(NodeBindingDefinition {
1075 point_id: node_id.clone(),
1076 node_id,
1077 label: Some(browse_name),
1078 writable: Some(self.writable),
1079 historizing: Some(self.historizing),
1080 sampling_interval_ms: defaults.min_publishing_interval_ms,
1081 seed: None,
1082 tags: Tags::new(),
1083 });
1084 }
1085 let model_name = format!("preset-{}", preset_name);
1086 let device_name = format!("preset-device-{}", preset_name);
1087 (
1088 model_name.clone(),
1089 ModelDefinition {
1090 nodesets: Vec::new(),
1091 namespace_uri: Some(defaults.namespace_uri.clone()),
1092 companions: Vec::new(),
1093 overlays,
1094 references: vec![ReferenceDefinition {
1095 source_node_id: NodeId::objects_folder().to_string(),
1096 reference_type: ReferenceTypeId::Organizes,
1097 target_node_id: folder_id.clone(),
1098 direction: ReferenceDirection::Forward,
1099 }],
1100 methods: Vec::new(),
1101 events: Vec::new(),
1102 },
1103 device_name,
1104 DeviceDefinition {
1105 model: model_name,
1106 node_bindings: bindings,
1107 tags: Tags::new()
1108 .with_label("generated")
1109 .with_tag("preset", preset_name),
1110 name: Some(format!("{} Generated Device", service_name)),
1111 },
1112 )
1113 }
1114}
1115
1116#[derive(Debug, Clone, Serialize, Deserialize)]
1118#[serde(tag = "kind", rename_all = "snake_case")]
1119pub enum OverlayNodeDefinition {
1120 Folder {
1121 node_id: String,
1122 browse_name: String,
1123 #[serde(default)]
1124 display_name: Option<String>,
1125 #[serde(default)]
1126 description: Option<String>,
1127 },
1128 Object {
1129 node_id: String,
1130 browse_name: String,
1131 #[serde(default)]
1132 display_name: Option<String>,
1133 #[serde(default)]
1134 description: Option<String>,
1135 #[serde(default)]
1136 event_notifier: Option<u8>,
1137 },
1138 Variable {
1139 node_id: String,
1140 browse_name: String,
1141 #[serde(default)]
1142 display_name: Option<String>,
1143 #[serde(default)]
1144 description: Option<String>,
1145 #[serde(default)]
1146 data_type: Option<String>,
1147 #[serde(default)]
1148 value: Option<Value>,
1149 #[serde(default)]
1150 writable: bool,
1151 #[serde(default)]
1152 historizing: bool,
1153 #[serde(default)]
1154 sampling_interval_ms: Option<u32>,
1155 },
1156}
1157
1158impl OverlayNodeDefinition {
1159 fn compile(&self) -> OpcUaResult<GeneratedNodeDefinition> {
1160 match self {
1161 Self::Folder {
1162 node_id,
1163 browse_name,
1164 display_name,
1165 description,
1166 } => Ok(GeneratedNodeDefinition::Object {
1167 node_id: parse_node_id(node_id)?,
1168 browse_name: parse_browse_name(browse_name),
1169 display_name: localized_text(display_name.as_deref().unwrap_or(browse_name)),
1170 description: description.clone().map(localized_text_owned),
1171 event_notifier: 0,
1172 folder_like: true,
1173 }),
1174 Self::Object {
1175 node_id,
1176 browse_name,
1177 display_name,
1178 description,
1179 event_notifier,
1180 } => Ok(GeneratedNodeDefinition::Object {
1181 node_id: parse_node_id(node_id)?,
1182 browse_name: parse_browse_name(browse_name),
1183 display_name: localized_text(display_name.as_deref().unwrap_or(browse_name)),
1184 description: description.clone().map(localized_text_owned),
1185 event_notifier: event_notifier.unwrap_or(0),
1186 folder_like: false,
1187 }),
1188 Self::Variable {
1189 node_id,
1190 browse_name,
1191 display_name,
1192 description,
1193 data_type,
1194 value,
1195 writable,
1196 historizing,
1197 sampling_interval_ms,
1198 } => Ok(GeneratedNodeDefinition::Variable {
1199 node_id: parse_node_id(node_id)?,
1200 browse_name: parse_browse_name(browse_name),
1201 display_name: localized_text(display_name.as_deref().unwrap_or(browse_name)),
1202 description: description.clone().map(localized_text_owned),
1203 data_type: data_type
1204 .as_ref()
1205 .map(|node_id| parse_node_id(node_id))
1206 .transpose()?
1207 .unwrap_or_else(|| NodeId::numeric(0, 11)),
1208 value: value.clone().unwrap_or(Value::Null),
1209 writable: *writable,
1210 historizing: *historizing,
1211 sampling_interval_ms: *sampling_interval_ms,
1212 }),
1213 }
1214 }
1215}
1216
1217#[derive(Debug, Clone, Serialize, Deserialize)]
1219pub struct ReferenceDefinition {
1220 pub source_node_id: String,
1221 pub reference_type: ReferenceTypeId,
1222 pub target_node_id: String,
1223 #[serde(default = "default_reference_direction")]
1224 pub direction: ReferenceDirection,
1225}
1226
1227impl ReferenceDefinition {
1228 fn compile(&self) -> OpcUaResult<CompiledNodeReference> {
1229 Ok(CompiledNodeReference {
1230 source_node_id: parse_node_id(&self.source_node_id)?,
1231 reference_type: self.reference_type,
1232 target_node_id: parse_node_id(&self.target_node_id)?,
1233 direction: self.direction,
1234 })
1235 }
1236}
1237
1238#[derive(Debug, Clone, Serialize, Deserialize)]
1240pub struct MethodDefinition {
1241 pub node_id: String,
1242 pub parent_id: String,
1243 pub browse_name: String,
1244 #[serde(default)]
1245 pub display_name: Option<String>,
1246 #[serde(default = "default_true")]
1247 pub executable: bool,
1248}
1249
1250impl MethodDefinition {
1251 fn compile(&self) -> OpcUaResult<(GeneratedNodeDefinition, Option<CompiledNodeReference>)> {
1252 Ok((
1253 GeneratedNodeDefinition::Method {
1254 node_id: parse_node_id(&self.node_id)?,
1255 browse_name: parse_browse_name(&self.browse_name),
1256 display_name: localized_text(
1257 self.display_name
1258 .as_deref()
1259 .unwrap_or(self.browse_name.as_str()),
1260 ),
1261 description: None,
1262 executable: self.executable,
1263 },
1264 Some(CompiledNodeReference {
1265 source_node_id: parse_node_id(&self.parent_id)?,
1266 reference_type: ReferenceTypeId::HasComponent,
1267 target_node_id: parse_node_id(&self.node_id)?,
1268 direction: ReferenceDirection::Forward,
1269 }),
1270 ))
1271 }
1272}
1273
1274#[derive(Debug, Clone, Serialize, Deserialize)]
1276pub struct EventDefinition {
1277 pub event_type: String,
1278 #[serde(default)]
1279 pub source_node_id: Option<String>,
1280 #[serde(default)]
1281 pub description: Option<String>,
1282}
1283
1284#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1286pub struct NamespaceCompilationPlan {
1287 pub namespaces: Vec<String>,
1288 pub sources: Vec<String>,
1289 pub models: Vec<String>,
1290}
1291
1292#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1294pub struct GeneratedNodeCatalog {
1295 pub namespace_table: Vec<String>,
1296 pub namespace_plan: NamespaceCompilationPlan,
1297 pub nodes: Vec<GeneratedNodeDefinition>,
1298 pub references: Vec<CompiledNodeReference>,
1299 pub type_tree_seeds: Vec<TypeTreeSeed>,
1300 pub methods: Vec<MethodDefinition>,
1301 pub events: Vec<EventDefinition>,
1302 pub point_bindings: Vec<CompiledPointBinding>,
1303}
1304
1305#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1307pub struct GeneratedTypeCatalog {
1308 pub session_name: String,
1309 pub module_name: String,
1310 pub entries: Vec<GeneratedTypeEntry>,
1311}
1312
1313#[derive(Debug, Clone, Serialize, Deserialize)]
1315pub struct GeneratedTypeEntry {
1316 pub rust_ident: String,
1317 pub node_id: String,
1318 pub browse_name: String,
1319 pub display_name: String,
1320 pub node_class: String,
1321}
1322
1323#[derive(Debug, Clone, Serialize, Deserialize)]
1325pub struct GeneratedRustModule {
1326 pub module_name: String,
1327 pub source: String,
1328 pub manifest_json: String,
1329}
1330
1331#[derive(Debug, Clone, Serialize, Deserialize)]
1333pub struct CompiledSecurityProfile {
1334 pub name: String,
1335 pub policy: String,
1336 pub mode: MessageSecurityMode,
1337 pub deprecated_policies: DeprecatedPolicyHandling,
1338 pub audit_sink: SecurityAuditSinkConfig,
1339 pub role_rules: Vec<RoleMappingRule>,
1340 pub allow_trust_reload: bool,
1341 pub allow_certificate_rotation: bool,
1342 pub manager_config: SecurityManagerConfig,
1343}
1344
1345impl GeneratedNodeCatalog {
1346 pub fn materialize(&self, address_space: &AddressSpace) -> OpcUaResult<()> {
1348 for namespace_uri in self.namespace_table.iter().skip(1) {
1349 address_space.register_namespace(namespace_uri);
1350 }
1351 for node in &self.nodes {
1352 node.insert_into(address_space);
1353 }
1354 for reference in &self.references {
1355 address_space.add_reference(reference.as_reference());
1356 }
1357 Ok(())
1358 }
1359
1360 pub fn namespace_summary(&self) -> Vec<String> {
1362 self.namespace_table.clone()
1363 }
1364}
1365
1366#[derive(Debug, Clone, Serialize, Deserialize)]
1368pub struct CompiledOpcUaSession {
1369 pub session_name: String,
1370 pub launch: ProtocolLaunchSpec,
1371 pub namespace_plan: NamespaceCompilationPlan,
1372 pub catalog: GeneratedNodeCatalog,
1373 pub devices: Vec<CompiledDeviceDefinition>,
1374 pub control: SessionControlConfig,
1375 pub runtime: SessionRuntimeConfig,
1376 pub generated_types: GeneratedTypeCatalog,
1377 pub security: CompiledSecurityProfile,
1378 pub readiness_timeout_ms: Option<u64>,
1379}
1380
1381impl CompiledOpcUaSession {
1382 pub fn runtime_extensions(&self) -> RuntimeExtensions {
1384 RuntimeExtensions::default()
1385 }
1386}
1387
1388#[derive(Debug, Clone, Serialize, Deserialize)]
1390pub struct OpcUaCompiledLaunchConfig {
1391 pub session_name: String,
1392 pub server_config: OpcUaServerConfig,
1393 pub catalog: GeneratedNodeCatalog,
1394 pub devices: Vec<CompiledDeviceDefinition>,
1395 pub control: SessionControlConfig,
1396 pub runtime: SessionRuntimeConfig,
1397 pub generated_types: GeneratedTypeCatalog,
1398 pub security: CompiledSecurityProfile,
1399 pub readiness_timeout_ms: Option<u64>,
1400}
1401
1402#[derive(Debug, Clone, Serialize, Deserialize)]
1404pub struct CompiledDeviceDefinition {
1405 pub device_id: String,
1406 pub name: String,
1407 #[serde(default, skip_serializing_if = "Tags::is_empty")]
1408 pub tags: Tags,
1409 #[serde(default)]
1410 pub points: Vec<CompiledPointBinding>,
1411}
1412
1413#[derive(Debug, Clone, Serialize, Deserialize)]
1415pub struct CompiledPointBinding {
1416 pub device_id: String,
1417 pub point_id: String,
1418 pub node_id: NodeId,
1419 pub node_class: String,
1420 pub display_name: String,
1421 pub browse_name: String,
1422 pub writable: bool,
1423 pub historizing: bool,
1424 pub sampling_interval_ms: Option<u32>,
1425 pub data_type: DataType,
1426 pub point_def: DataPointDef,
1427 #[serde(default, skip_serializing_if = "Tags::is_empty")]
1428 pub tags: Tags,
1429}
1430
1431#[derive(Debug, Clone, Serialize, Deserialize)]
1433pub struct CompiledNodeReference {
1434 pub source_node_id: NodeId,
1435 pub reference_type: ReferenceTypeId,
1436 pub target_node_id: NodeId,
1437 pub direction: ReferenceDirection,
1438}
1439
1440impl CompiledNodeReference {
1441 fn as_reference(&self) -> Reference {
1442 match self.direction {
1443 ReferenceDirection::Forward => Reference::forward(
1444 self.source_node_id.clone(),
1445 self.reference_type,
1446 self.target_node_id.clone(),
1447 ),
1448 ReferenceDirection::Inverse => Reference::inverse(
1449 self.source_node_id.clone(),
1450 self.reference_type,
1451 self.target_node_id.clone(),
1452 ),
1453 }
1454 }
1455
1456 fn remap_namespaces(&self, mapping: &BTreeMap<u16, u16>) -> OpcUaResult<Self> {
1457 Ok(Self {
1458 source_node_id: remap_node_id(&self.source_node_id, mapping)?,
1459 reference_type: self.reference_type,
1460 target_node_id: remap_node_id(&self.target_node_id, mapping)?,
1461 direction: self.direction,
1462 })
1463 }
1464}
1465
1466#[derive(Debug, Clone, Serialize, Deserialize)]
1468pub struct TypeTreeSeed {
1469 pub namespace_uri: String,
1470}
1471
1472#[derive(Debug, Clone, Serialize, Deserialize)]
1474#[serde(tag = "kind", rename_all = "snake_case")]
1475pub enum GeneratedNodeDefinition {
1476 Object {
1477 node_id: NodeId,
1478 browse_name: QualifiedName,
1479 display_name: LocalizedText,
1480 description: Option<LocalizedText>,
1481 event_notifier: u8,
1482 folder_like: bool,
1483 },
1484 Variable {
1485 node_id: NodeId,
1486 browse_name: QualifiedName,
1487 display_name: LocalizedText,
1488 description: Option<LocalizedText>,
1489 data_type: NodeId,
1490 value: Value,
1491 writable: bool,
1492 historizing: bool,
1493 sampling_interval_ms: Option<u32>,
1494 },
1495 Method {
1496 node_id: NodeId,
1497 browse_name: QualifiedName,
1498 display_name: LocalizedText,
1499 description: Option<LocalizedText>,
1500 executable: bool,
1501 },
1502 ObjectType {
1503 node_id: NodeId,
1504 browse_name: QualifiedName,
1505 display_name: LocalizedText,
1506 },
1507 VariableType {
1508 node_id: NodeId,
1509 browse_name: QualifiedName,
1510 display_name: LocalizedText,
1511 data_type: NodeId,
1512 },
1513 ReferenceType {
1514 node_id: NodeId,
1515 browse_name: QualifiedName,
1516 display_name: LocalizedText,
1517 },
1518 DataType {
1519 node_id: NodeId,
1520 browse_name: QualifiedName,
1521 display_name: LocalizedText,
1522 },
1523 View {
1524 node_id: NodeId,
1525 browse_name: QualifiedName,
1526 display_name: LocalizedText,
1527 },
1528}
1529
1530impl GeneratedNodeDefinition {
1531 pub fn node_id(&self) -> &NodeId {
1532 match self {
1533 Self::Object { node_id, .. }
1534 | Self::Variable { node_id, .. }
1535 | Self::Method { node_id, .. }
1536 | Self::ObjectType { node_id, .. }
1537 | Self::VariableType { node_id, .. }
1538 | Self::ReferenceType { node_id, .. }
1539 | Self::DataType { node_id, .. }
1540 | Self::View { node_id, .. } => node_id,
1541 }
1542 }
1543
1544 pub fn browse_name(&self) -> &QualifiedName {
1545 match self {
1546 Self::Object { browse_name, .. }
1547 | Self::Variable { browse_name, .. }
1548 | Self::Method { browse_name, .. }
1549 | Self::ObjectType { browse_name, .. }
1550 | Self::VariableType { browse_name, .. }
1551 | Self::ReferenceType { browse_name, .. }
1552 | Self::DataType { browse_name, .. }
1553 | Self::View { browse_name, .. } => browse_name,
1554 }
1555 }
1556
1557 pub fn display_name(&self) -> &LocalizedText {
1558 match self {
1559 Self::Object { display_name, .. }
1560 | Self::Variable { display_name, .. }
1561 | Self::Method { display_name, .. }
1562 | Self::ObjectType { display_name, .. }
1563 | Self::VariableType { display_name, .. }
1564 | Self::ReferenceType { display_name, .. }
1565 | Self::DataType { display_name, .. }
1566 | Self::View { display_name, .. } => display_name,
1567 }
1568 }
1569
1570 pub fn node_class_name(&self) -> &'static str {
1571 match self {
1572 Self::Object {
1573 folder_like: true, ..
1574 } => "folder",
1575 Self::Object { .. } => "object",
1576 Self::Variable { .. } => "variable",
1577 Self::Method { .. } => "method",
1578 Self::ObjectType { .. } => "object_type",
1579 Self::VariableType { .. } => "variable_type",
1580 Self::ReferenceType { .. } => "reference_type",
1581 Self::DataType { .. } => "data_type",
1582 Self::View { .. } => "view",
1583 }
1584 }
1585
1586 pub fn is_variable(&self) -> bool {
1587 matches!(self, Self::Variable { .. })
1588 }
1589
1590 fn remap_namespaces(&self, mapping: &BTreeMap<u16, u16>) -> OpcUaResult<Self> {
1591 Ok(match self {
1592 Self::Object {
1593 node_id,
1594 browse_name,
1595 display_name,
1596 description,
1597 event_notifier,
1598 folder_like,
1599 } => Self::Object {
1600 node_id: remap_node_id(node_id, mapping)?,
1601 browse_name: remap_qualified_name(browse_name, mapping),
1602 display_name: display_name.clone(),
1603 description: description.clone(),
1604 event_notifier: *event_notifier,
1605 folder_like: *folder_like,
1606 },
1607 Self::Variable {
1608 node_id,
1609 browse_name,
1610 display_name,
1611 description,
1612 data_type,
1613 value,
1614 writable,
1615 historizing,
1616 sampling_interval_ms,
1617 } => Self::Variable {
1618 node_id: remap_node_id(node_id, mapping)?,
1619 browse_name: remap_qualified_name(browse_name, mapping),
1620 display_name: display_name.clone(),
1621 description: description.clone(),
1622 data_type: remap_node_id(data_type, mapping)?,
1623 value: value.clone(),
1624 writable: *writable,
1625 historizing: *historizing,
1626 sampling_interval_ms: *sampling_interval_ms,
1627 },
1628 Self::Method {
1629 node_id,
1630 browse_name,
1631 display_name,
1632 description,
1633 executable,
1634 } => Self::Method {
1635 node_id: remap_node_id(node_id, mapping)?,
1636 browse_name: remap_qualified_name(browse_name, mapping),
1637 display_name: display_name.clone(),
1638 description: description.clone(),
1639 executable: *executable,
1640 },
1641 Self::ObjectType {
1642 node_id,
1643 browse_name,
1644 display_name,
1645 } => Self::ObjectType {
1646 node_id: remap_node_id(node_id, mapping)?,
1647 browse_name: remap_qualified_name(browse_name, mapping),
1648 display_name: display_name.clone(),
1649 },
1650 Self::VariableType {
1651 node_id,
1652 browse_name,
1653 display_name,
1654 data_type,
1655 } => Self::VariableType {
1656 node_id: remap_node_id(node_id, mapping)?,
1657 browse_name: remap_qualified_name(browse_name, mapping),
1658 display_name: display_name.clone(),
1659 data_type: remap_node_id(data_type, mapping)?,
1660 },
1661 Self::ReferenceType {
1662 node_id,
1663 browse_name,
1664 display_name,
1665 } => Self::ReferenceType {
1666 node_id: remap_node_id(node_id, mapping)?,
1667 browse_name: remap_qualified_name(browse_name, mapping),
1668 display_name: display_name.clone(),
1669 },
1670 Self::DataType {
1671 node_id,
1672 browse_name,
1673 display_name,
1674 } => Self::DataType {
1675 node_id: remap_node_id(node_id, mapping)?,
1676 browse_name: remap_qualified_name(browse_name, mapping),
1677 display_name: display_name.clone(),
1678 },
1679 Self::View {
1680 node_id,
1681 browse_name,
1682 display_name,
1683 } => Self::View {
1684 node_id: remap_node_id(node_id, mapping)?,
1685 browse_name: remap_qualified_name(browse_name, mapping),
1686 display_name: display_name.clone(),
1687 },
1688 })
1689 }
1690
1691 fn insert_into(&self, address_space: &AddressSpace) {
1692 match self {
1693 Self::Object {
1694 node_id,
1695 browse_name,
1696 display_name,
1697 description,
1698 event_notifier,
1699 ..
1700 } => {
1701 let mut node =
1702 ObjectNode::new(node_id.clone(), browse_name.clone(), display_name.clone())
1703 .with_event_notifier(*event_notifier);
1704 if let Some(description) = description {
1705 node = node.with_description(description.clone());
1706 }
1707 address_space.insert_node(node);
1708 }
1709 Self::Variable {
1710 node_id,
1711 browse_name,
1712 display_name,
1713 description,
1714 data_type,
1715 value,
1716 writable,
1717 historizing,
1718 sampling_interval_ms,
1719 } => {
1720 let mut node = VariableNode::new(
1721 node_id.clone(),
1722 browse_name.clone(),
1723 display_name.clone(),
1724 data_type.clone(),
1725 Variant::from(value.clone()),
1726 );
1727 if let Some(description) = description {
1728 node = node.with_description(description.clone());
1729 }
1730 if *writable {
1731 node = node.writable();
1732 }
1733 node = node.with_historizing(*historizing);
1734 if let Some(sampling_interval_ms) = sampling_interval_ms {
1735 node = node.with_minimum_sampling_interval(*sampling_interval_ms as f64);
1736 }
1737 address_space.insert_node(node);
1738 }
1739 Self::Method {
1740 node_id,
1741 browse_name,
1742 display_name,
1743 description,
1744 executable,
1745 } => {
1746 let mut node =
1747 MethodNode::new(node_id.clone(), browse_name.clone(), display_name.clone())
1748 .with_executable(*executable);
1749 if let Some(description) = description {
1750 node = node.with_description(description.clone());
1751 }
1752 address_space.insert_node(node);
1753 }
1754 Self::ObjectType {
1755 node_id,
1756 browse_name,
1757 display_name,
1758 } => {
1759 address_space.insert_node(ObjectTypeNode::new(
1760 node_id.clone(),
1761 browse_name.clone(),
1762 display_name.clone(),
1763 ));
1764 }
1765 Self::VariableType {
1766 node_id,
1767 browse_name,
1768 display_name,
1769 data_type,
1770 } => {
1771 address_space.insert_node(VariableTypeNode::new(
1772 node_id.clone(),
1773 browse_name.clone(),
1774 display_name.clone(),
1775 data_type.clone(),
1776 ));
1777 }
1778 Self::ReferenceType {
1779 node_id,
1780 browse_name,
1781 display_name,
1782 } => {
1783 address_space.insert_node(ReferenceTypeNode::new(
1784 node_id.clone(),
1785 browse_name.clone(),
1786 display_name.clone(),
1787 ));
1788 }
1789 Self::DataType {
1790 node_id,
1791 browse_name,
1792 display_name,
1793 } => {
1794 address_space.insert_node(DataTypeNode::new(
1795 node_id.clone(),
1796 browse_name.clone(),
1797 display_name.clone(),
1798 ));
1799 }
1800 Self::View {
1801 node_id,
1802 browse_name,
1803 display_name,
1804 } => {
1805 address_space.insert_node(ViewNode::new(
1806 node_id.clone(),
1807 browse_name.clone(),
1808 display_name.clone(),
1809 ));
1810 }
1811 }
1812 }
1813}
1814
1815#[derive(Debug, Clone, Serialize)]
1817pub struct OpcUaSchemaSummary {
1818 pub kind: &'static str,
1819 pub formats: Vec<&'static str>,
1820 pub top_level_sections: Vec<SchemaSection>,
1821 pub commands: Vec<&'static str>,
1822 pub notes: Vec<&'static str>,
1823}
1824
1825#[derive(Debug, Clone, Serialize)]
1827pub struct SchemaSection {
1828 pub name: &'static str,
1829 pub required: bool,
1830 pub purpose: &'static str,
1831}
1832
1833impl SchemaSection {
1834 fn new(name: &'static str, required: bool, purpose: &'static str) -> Self {
1835 Self {
1836 name,
1837 required,
1838 purpose,
1839 }
1840 }
1841}
1842
1843#[derive(Debug, Clone, Serialize)]
1845pub struct OpcUaConfigSummary {
1846 pub transports: Vec<String>,
1847 pub security_profiles: Vec<String>,
1848 pub nodesets: Vec<String>,
1849 pub companion_packs: Vec<String>,
1850 pub models: Vec<String>,
1851 pub devices: Vec<String>,
1852 pub sessions: Vec<OpcUaSessionSummary>,
1853 pub presets: Vec<String>,
1854 pub generated_types_enabled: bool,
1855}
1856
1857#[derive(Debug, Clone, Serialize)]
1859pub struct OpcUaSessionSummary {
1860 pub name: String,
1861 pub transport: String,
1862 pub transport_protocol: TransportProtocol,
1863 pub transport_connection_mode: TransportConnectionMode,
1864 pub models: Vec<String>,
1865 pub devices: Vec<String>,
1866 pub preset: Option<String>,
1867 pub service_name: Option<String>,
1868}
1869
1870fn build_server_config(
1871 defaults: &SimulatorDefaults,
1872 session: &SessionDefinition,
1873 transport: &TransportDefinition,
1874 security: &CompiledSecurityProfile,
1875 base_path: Option<&Path>,
1876) -> OpcUaServerConfig {
1877 let server_name = transport
1878 .server_name
1879 .clone()
1880 .or_else(|| session.service_name.clone())
1881 .unwrap_or_else(|| defaults.server_name.clone());
1882 let endpoint_url = format!(
1883 "{}://{}:{}{}",
1884 transport.protocol.scheme(),
1885 transport.bind,
1886 transport.port,
1887 transport.endpoint_path
1888 );
1889 let mut config = OpcUaServerConfig {
1890 endpoint_url,
1891 endpoint_protocol: transport.protocol,
1892 connection_mode: transport.connection_mode,
1893 reverse_connect_target: transport.reverse_connect_target.clone(),
1894 retry_interval_ms: transport.retry_interval_ms,
1895 server_name,
1896 security_policy: security.policy.clone(),
1897 certificate_path: resolve_optional_path(base_path, transport.certificate_path.as_ref()),
1898 private_key_path: resolve_optional_path(base_path, transport.private_key_path.as_ref()),
1899 ..Default::default()
1900 };
1901 if let Some(min_publishing_interval_ms) = defaults.min_publishing_interval_ms {
1902 config.min_publishing_interval_ms = min_publishing_interval_ms;
1903 }
1904 config
1905}
1906
1907fn resolve_optional_path(base_path: Option<&Path>, path: Option<&PathBuf>) -> Option<PathBuf> {
1908 let path = path?;
1909 if path.is_absolute() {
1910 return Some(path.clone());
1911 }
1912 base_path
1913 .and_then(|base| base.parent())
1914 .map(|parent| parent.join(path))
1915 .or_else(|| Some(path.clone()))
1916}
1917
1918fn default_security_profile_policy() -> String {
1919 "None".into()
1920}
1921
1922fn default_retry_interval_ms() -> u64 {
1923 5_000
1924}
1925
1926fn resolve_security_profile_definition(
1927 config: &OpcUaSimulatorConfig,
1928 name: &str,
1929) -> Option<(String, SecurityProfileDefinition)> {
1930 if let Some(profile) = config.security_profiles.get(name) {
1931 return Some((name.to_string(), profile.clone()));
1932 }
1933
1934 builtin_security_profile(name).map(|profile| (name.to_string(), profile))
1935}
1936
1937fn builtin_security_profile(name: &str) -> Option<SecurityProfileDefinition> {
1938 let policy = match name {
1939 "None" => "None",
1940 "Basic128Rsa15" => "Basic128Rsa15",
1941 "Basic256" => "Basic256",
1942 "Basic256Sha256" => "Basic256Sha256",
1943 "Aes128Sha256RsaOaep" => "Aes128Sha256RsaOaep",
1944 "Aes256Sha256RsaPss" => "Aes256Sha256RsaPss",
1945 _ => return None,
1946 };
1947
1948 Some(SecurityProfileDefinition {
1949 policy: policy.into(),
1950 mode: Some(if policy == "None" {
1951 MessageSecurityMode::None
1952 } else {
1953 MessageSecurityMode::SignAndEncrypt
1954 }),
1955 ..Default::default()
1956 })
1957}
1958
1959fn compile_security_profile(
1960 (name, profile): &(String, SecurityProfileDefinition),
1961) -> CompiledSecurityProfile {
1962 let policy = profile.policy.clone();
1963 let mut manager_config = SecurityManagerConfig::default();
1964 manager_config.default_policy = parse_security_policy_name(&policy);
1965 manager_config.enabled_policies =
1966 vec![SecurityPolicy::None, parse_security_policy_name(&policy)];
1967 manager_config.reject_deprecated_policies = matches!(
1968 profile.deprecated_policies,
1969 DeprecatedPolicyHandling::Reject
1970 );
1971 manager_config.deprecated_policy_handling = profile.deprecated_policies;
1972 manager_config.audit_sink = profile.audit_sink.clone();
1973 manager_config.role_rules = profile.role_rules.clone();
1974
1975 CompiledSecurityProfile {
1976 name: name.clone(),
1977 policy,
1978 mode: profile.mode.unwrap_or(MessageSecurityMode::None),
1979 deprecated_policies: profile.deprecated_policies,
1980 audit_sink: profile.audit_sink.clone(),
1981 role_rules: profile.role_rules.clone(),
1982 allow_trust_reload: profile.allow_trust_reload,
1983 allow_certificate_rotation: profile.allow_certificate_rotation,
1984 manager_config,
1985 }
1986}
1987
1988fn parse_security_policy_name(value: &str) -> SecurityPolicy {
1989 match value {
1990 "Basic128Rsa15" => SecurityPolicy::Basic128Rsa15,
1991 "Basic256" => SecurityPolicy::Basic256,
1992 "Basic256Sha256" => SecurityPolicy::Basic256Sha256,
1993 "Aes128Sha256RsaOaep" => SecurityPolicy::Aes128Sha256RsaOaep,
1994 "Aes256Sha256RsaPss" => SecurityPolicy::Aes256Sha256RsaPss,
1995 _ => SecurityPolicy::None,
1996 }
1997}
1998
1999fn compile_device(
2000 device_name: &str,
2001 device: &DeviceDefinition,
2002 node_map: &BTreeMap<String, GeneratedNodeDefinition>,
2003) -> OpcUaResult<CompiledDeviceDefinition> {
2004 let mut bindings = if device.node_bindings.is_empty() {
2005 auto_bind_model(device_name, device, node_map)?
2006 } else {
2007 let mut bindings = Vec::new();
2008 for binding in &device.node_bindings {
2009 bindings.push(compile_binding(device_name, device, binding, node_map)?);
2010 }
2011 bindings
2012 };
2013
2014 bindings.sort_by(|left, right| left.point_id.cmp(&right.point_id));
2015
2016 Ok(CompiledDeviceDefinition {
2017 device_id: device_name.to_string(),
2018 name: device
2019 .name
2020 .clone()
2021 .unwrap_or_else(|| device_name.replace('-', " ")),
2022 tags: device.tags.clone(),
2023 points: bindings,
2024 })
2025}
2026
2027fn auto_bind_model(
2028 device_name: &str,
2029 device: &DeviceDefinition,
2030 node_map: &BTreeMap<String, GeneratedNodeDefinition>,
2031) -> OpcUaResult<Vec<CompiledPointBinding>> {
2032 let mut bindings = Vec::new();
2033 for node in node_map.values() {
2034 if let GeneratedNodeDefinition::Variable {
2035 node_id,
2036 display_name,
2037 browse_name,
2038 writable,
2039 historizing,
2040 sampling_interval_ms,
2041 data_type,
2042 ..
2043 } = node
2044 {
2045 let point_id = node_id.to_string();
2046 bindings.push(compiled_binding_from_node(
2047 device_name,
2048 &device.tags,
2049 &point_id,
2050 node,
2051 *writable,
2052 *historizing,
2053 *sampling_interval_ms,
2054 None,
2055 browse_name.name.as_str(),
2056 display_name.text.as_str(),
2057 data_type,
2058 )?);
2059 }
2060 }
2061 Ok(bindings)
2062}
2063
2064fn compile_binding(
2065 device_name: &str,
2066 device: &DeviceDefinition,
2067 binding: &NodeBindingDefinition,
2068 node_map: &BTreeMap<String, GeneratedNodeDefinition>,
2069) -> OpcUaResult<CompiledPointBinding> {
2070 let node = node_map.get(&binding.node_id).ok_or_else(|| {
2071 OpcUaError::Config(format!(
2072 "device '{}' references unknown node '{}'",
2073 device_name, binding.node_id
2074 ))
2075 })?;
2076 let GeneratedNodeDefinition::Variable {
2077 browse_name,
2078 display_name,
2079 writable,
2080 historizing,
2081 sampling_interval_ms,
2082 data_type,
2083 ..
2084 } = node
2085 else {
2086 return Err(OpcUaError::Config(format!(
2087 "device '{}' binding '{}' targets non-variable node '{}'",
2088 device_name, binding.point_id, binding.node_id
2089 )));
2090 };
2091 let merged_tags = device.tags.clone().merged_with(binding.tags.clone());
2092 compiled_binding_from_node(
2093 device_name,
2094 &merged_tags,
2095 &binding.point_id,
2096 node,
2097 binding.writable.unwrap_or(*writable),
2098 binding.historizing.unwrap_or(*historizing),
2099 binding.sampling_interval_ms.or(*sampling_interval_ms),
2100 binding.seed.clone(),
2101 binding
2102 .label
2103 .as_deref()
2104 .unwrap_or(browse_name.name.as_str()),
2105 display_name.text.as_str(),
2106 data_type,
2107 )
2108}
2109
2110fn compiled_binding_from_node(
2111 device_name: &str,
2112 tags: &Tags,
2113 point_id: &str,
2114 node: &GeneratedNodeDefinition,
2115 writable: bool,
2116 historizing: bool,
2117 sampling_interval_ms: Option<u32>,
2118 seed: Option<Value>,
2119 label: &str,
2120 display_name: &str,
2121 data_type: &NodeId,
2122) -> OpcUaResult<CompiledPointBinding> {
2123 let node_id = node.node_id().clone();
2124 let data_type_kind = map_data_type(data_type);
2125 let access = if writable {
2126 AccessMode::ReadWrite
2127 } else {
2128 AccessMode::ReadOnly
2129 };
2130 let mut point_def = DataPointDef::new(point_id, label, data_type_kind)
2131 .with_access(access)
2132 .with_address(Address::OpcUa {
2133 node_id: node_id.to_string(),
2134 });
2135 if let Some(seed) = seed {
2136 point_def.default_value = Some(seed);
2137 }
2138 if !matches!(node, GeneratedNodeDefinition::Variable { .. }) {
2139 return Err(OpcUaError::Config(format!(
2140 "point '{}' must bind to a variable node",
2141 point_id
2142 )));
2143 }
2144 Ok(CompiledPointBinding {
2145 device_id: device_name.to_string(),
2146 point_id: point_id.to_string(),
2147 node_id,
2148 node_class: node.node_class_name().to_string(),
2149 display_name: display_name.to_string(),
2150 browse_name: node.browse_name().name.clone(),
2151 writable,
2152 historizing,
2153 sampling_interval_ms,
2154 data_type: data_type_kind,
2155 point_def,
2156 tags: tags.clone(),
2157 })
2158}
2159
2160fn map_data_type(data_type: &NodeId) -> DataType {
2161 match data_type.as_numeric() {
2162 Some(1) => DataType::Bool,
2163 Some(2) => DataType::Int8,
2164 Some(3) => DataType::UInt8,
2165 Some(4) => DataType::Int16,
2166 Some(5) => DataType::UInt16,
2167 Some(6) => DataType::Int32,
2168 Some(7) => DataType::UInt32,
2169 Some(8) => DataType::Int64,
2170 Some(9) => DataType::UInt64,
2171 Some(10) => DataType::Float32,
2172 Some(11) => DataType::Float64,
2173 Some(12) => DataType::String,
2174 Some(13) => DataType::DateTime,
2175 Some(15) => DataType::ByteString,
2176 _ => DataType::Float64,
2177 }
2178}
2179
2180#[derive(Debug, Clone, Serialize, Deserialize)]
2181struct ImportedNodeSet {
2182 local_namespace_table: Vec<String>,
2183 nodes: Vec<GeneratedNodeDefinition>,
2184 references: Vec<CompiledNodeReference>,
2185}
2186
2187fn import_nodeset_source_cached(
2188 source: &NodeSetSource,
2189 base_path: Option<&Path>,
2190 cache: &ModelingCache,
2191 counters: &mut ImportCacheCounters,
2192) -> OpcUaResult<ImportedNodeSet> {
2193 let cache_key = build_import_cache_key(source, base_path)?;
2194 if let Some(imported) = cache.load_imported_nodeset(&cache_key) {
2195 counters.record_hit();
2196 return Ok(imported);
2197 }
2198
2199 let imported = import_nodeset_source_uncached(source, base_path)?;
2200 let _ = cache.save_imported_nodeset(&cache_key, &imported);
2201 counters.record_miss();
2202 Ok(imported)
2203}
2204
2205fn import_nodeset_source_uncached(
2206 source: &NodeSetSource,
2207 base_path: Option<&Path>,
2208) -> OpcUaResult<ImportedNodeSet> {
2209 match source {
2210 NodeSetSource::File {
2211 path,
2212 namespace_uri_override,
2213 } => {
2214 let resolved = resolve_path(base_path, path);
2215 let xml = fs::read_to_string(&resolved)?;
2216 import_nodeset_xml(&xml, namespace_uri_override.as_ref())
2217 }
2218 NodeSetSource::Embedded {
2219 alias,
2220 namespace_uri_override,
2221 } => import_embedded_nodeset(alias, namespace_uri_override.as_ref()),
2222 }
2223}
2224
2225fn validate_nodeset_source(
2226 name: &str,
2227 source: &NodeSetSource,
2228 base_path: Option<&Path>,
2229) -> OpcUaResult<()> {
2230 match source {
2231 NodeSetSource::File { path, .. } => {
2232 let resolved = resolve_path(base_path, path);
2233 if !resolved.exists() {
2234 return Err(OpcUaError::Config(format!(
2235 "nodeset '{}' path '{}' does not exist",
2236 name,
2237 resolved.display()
2238 )));
2239 }
2240 }
2241 NodeSetSource::Embedded { alias, .. } => {
2242 if !matches!(alias.as_str(), "minimal" | "demo" | "base_simulation") {
2243 return Err(OpcUaError::Config(format!(
2244 "nodeset '{}' references unsupported embedded alias '{}'",
2245 name, alias
2246 )));
2247 }
2248 }
2249 }
2250 Ok(())
2251}
2252
2253fn import_embedded_nodeset(
2254 alias: &str,
2255 namespace_uri_override: Option<&String>,
2256) -> OpcUaResult<ImportedNodeSet> {
2257 let namespace_uri = namespace_uri_override
2258 .cloned()
2259 .unwrap_or_else(|| format!("urn:mabinogion:opcua:embedded:{}", alias));
2260 match alias {
2261 "minimal" | "demo" | "base_simulation" => Ok(ImportedNodeSet {
2262 local_namespace_table: vec![STANDARD_NAMESPACE_URI.to_string(), namespace_uri],
2263 nodes: vec![
2264 GeneratedNodeDefinition::Object {
2265 node_id: NodeId::string(1, format!("embedded/{}", alias)),
2266 browse_name: QualifiedName::new(1, format!("Embedded{}", alias)),
2267 display_name: localized_text(&format!("Embedded {}", alias)),
2268 description: Some(localized_text("Embedded NodeSet2 alias")),
2269 event_notifier: 0,
2270 folder_like: false,
2271 },
2272 GeneratedNodeDefinition::Variable {
2273 node_id: NodeId::string(1, format!("embedded/{}/value", alias)),
2274 browse_name: QualifiedName::new(1, "Value"),
2275 display_name: localized_text("Value"),
2276 description: Some(localized_text("Embedded demo variable")),
2277 data_type: NodeId::numeric(0, 11),
2278 value: Value::F64(0.0),
2279 writable: true,
2280 historizing: false,
2281 sampling_interval_ms: Some(100),
2282 },
2283 ],
2284 references: vec![CompiledNodeReference {
2285 source_node_id: NodeId::string(1, format!("embedded/{}", alias)),
2286 reference_type: ReferenceTypeId::HasComponent,
2287 target_node_id: NodeId::string(1, format!("embedded/{}/value", alias)),
2288 direction: ReferenceDirection::Forward,
2289 }],
2290 }),
2291 _ => Err(OpcUaError::Config(format!(
2292 "unsupported embedded NodeSet alias '{}'",
2293 alias
2294 ))),
2295 }
2296}
2297
2298fn import_nodeset_xml(
2299 xml: &str,
2300 namespace_uri_override: Option<&String>,
2301) -> OpcUaResult<ImportedNodeSet> {
2302 let namespace_uris = parse_namespace_uris(xml, namespace_uri_override);
2303 let mut nodes = Vec::new();
2304 let mut references = Vec::new();
2305
2306 for tag in [
2307 "UAObject",
2308 "UAVariable",
2309 "UAMethod",
2310 "UAObjectType",
2311 "UAVariableType",
2312 "UAReferenceType",
2313 "UADataType",
2314 "UAView",
2315 ] {
2316 for element in collect_xml_elements(xml, tag) {
2317 let (node, node_references) = parse_nodeset_element(tag, &element)?;
2318 nodes.push(node);
2319 references.extend(node_references);
2320 }
2321 }
2322
2323 Ok(ImportedNodeSet {
2324 local_namespace_table: namespace_uris,
2325 nodes,
2326 references,
2327 })
2328}
2329
2330fn parse_nodeset_element(
2331 tag: &str,
2332 element: &XmlElement,
2333) -> OpcUaResult<(GeneratedNodeDefinition, Vec<CompiledNodeReference>)> {
2334 let node_id = parse_node_id(required_attr(element, "NodeId")?)?;
2335 let browse_name = parse_browse_name(required_attr(element, "BrowseName")?);
2336 let display_name = localized_text(
2337 element
2338 .text_of("DisplayName")
2339 .unwrap_or(browse_name.name.clone()),
2340 );
2341 let description = element.text_of("Description").map(localized_text_owned);
2342 let references = parse_reference_elements(node_id.clone(), element)?;
2343
2344 let node = match tag {
2345 "UAObject" => GeneratedNodeDefinition::Object {
2346 node_id,
2347 browse_name,
2348 display_name,
2349 description,
2350 event_notifier: parse_u8_attr(element.attr("EventNotifier")).unwrap_or(0),
2351 folder_like: false,
2352 },
2353 "UAVariable" => GeneratedNodeDefinition::Variable {
2354 node_id,
2355 browse_name,
2356 display_name,
2357 description,
2358 data_type: element
2359 .attr("DataType")
2360 .map(parse_node_id)
2361 .transpose()?
2362 .unwrap_or_else(|| NodeId::numeric(0, 11)),
2363 value: parse_value_element(element.value_xml()).unwrap_or(Value::Null),
2364 writable: parse_access_level(element.attr("AccessLevel")).can_write(),
2365 historizing: parse_bool_attr(element.attr("Historizing")).unwrap_or(false),
2366 sampling_interval_ms: parse_f64_attr(element.attr("MinimumSamplingInterval"))
2367 .map(|value| value.max(0.0) as u32),
2368 },
2369 "UAMethod" => GeneratedNodeDefinition::Method {
2370 node_id,
2371 browse_name,
2372 display_name,
2373 description,
2374 executable: parse_bool_attr(element.attr("Executable")).unwrap_or(true),
2375 },
2376 "UAObjectType" => GeneratedNodeDefinition::ObjectType {
2377 node_id,
2378 browse_name,
2379 display_name,
2380 },
2381 "UAVariableType" => GeneratedNodeDefinition::VariableType {
2382 node_id,
2383 browse_name,
2384 display_name,
2385 data_type: element
2386 .attr("DataType")
2387 .map(parse_node_id)
2388 .transpose()?
2389 .unwrap_or_else(|| NodeId::numeric(0, 11)),
2390 },
2391 "UAReferenceType" => GeneratedNodeDefinition::ReferenceType {
2392 node_id,
2393 browse_name,
2394 display_name,
2395 },
2396 "UADataType" => GeneratedNodeDefinition::DataType {
2397 node_id,
2398 browse_name,
2399 display_name,
2400 },
2401 "UAView" => GeneratedNodeDefinition::View {
2402 node_id,
2403 browse_name,
2404 display_name,
2405 },
2406 other => {
2407 return Err(OpcUaError::Config(format!(
2408 "unsupported NodeSet element '{}'",
2409 other
2410 )))
2411 }
2412 };
2413
2414 Ok((node, references))
2415}
2416
2417fn parse_reference_elements(
2418 source_node_id: NodeId,
2419 element: &XmlElement,
2420) -> OpcUaResult<Vec<CompiledNodeReference>> {
2421 let Some(references_block) = element.child_xml("References") else {
2422 return Ok(Vec::new());
2423 };
2424 let mut references = Vec::new();
2425 for reference in collect_xml_elements(references_block, "Reference") {
2426 let reference_type = parse_reference_type(required_attr(&reference, "ReferenceType")?)?;
2427 let target_node_id = parse_node_id(reference.inner.trim())?;
2428 let direction = if reference.attr("IsForward") == Some("false") {
2429 ReferenceDirection::Inverse
2430 } else {
2431 ReferenceDirection::Forward
2432 };
2433 references.push(CompiledNodeReference {
2434 source_node_id: source_node_id.clone(),
2435 reference_type,
2436 target_node_id,
2437 direction,
2438 });
2439 }
2440 Ok(references)
2441}
2442
2443fn parse_namespace_uris(xml: &str, override_uri: Option<&String>) -> Vec<String> {
2444 let mut table = vec![STANDARD_NAMESPACE_URI.to_string()];
2445 if let Some(block) = child_xml_block(xml, "NamespaceUris") {
2446 for element in collect_xml_elements(block, "Uri") {
2447 let value = element.inner.trim();
2448 if !value.is_empty() {
2449 table.push(value.to_string());
2450 }
2451 }
2452 }
2453 if let Some(override_uri) = override_uri {
2454 if table.len() == 1 {
2455 table.push(override_uri.clone());
2456 } else if let Some(slot) = table.get_mut(1) {
2457 *slot = override_uri.clone();
2458 }
2459 }
2460 table
2461}
2462
2463fn collect_type_tree_seeds(namespace_table: &[String]) -> Vec<TypeTreeSeed> {
2464 namespace_table
2465 .iter()
2466 .cloned()
2467 .map(|namespace_uri| TypeTreeSeed { namespace_uri })
2468 .collect()
2469}
2470
2471fn namespace_mapping(local: &[String], global: &[String]) -> OpcUaResult<BTreeMap<u16, u16>> {
2472 let mut mapping = BTreeMap::new();
2473 for (index, uri) in local.iter().enumerate() {
2474 let global_index = global
2475 .iter()
2476 .position(|candidate| candidate == uri)
2477 .ok_or_else(|| {
2478 OpcUaError::Config(format!(
2479 "missing namespace URI '{}' during compilation",
2480 uri
2481 ))
2482 })?;
2483 mapping.insert(index as u16, global_index as u16);
2484 }
2485 Ok(mapping)
2486}
2487
2488fn remap_node_id(node_id: &NodeId, mapping: &BTreeMap<u16, u16>) -> OpcUaResult<NodeId> {
2489 let namespace = mapping
2490 .get(&node_id.namespace())
2491 .copied()
2492 .unwrap_or(node_id.namespace());
2493 Ok(match node_id.identifier() {
2494 NodeIdType::Numeric(value) => NodeId::numeric(namespace, *value),
2495 NodeIdType::String(value) => NodeId::string(namespace, value.clone()),
2496 NodeIdType::Guid(value) => NodeId::guid(namespace, *value),
2497 NodeIdType::ByteString(value) => NodeId::byte_string(namespace, value.clone()),
2498 })
2499}
2500
2501fn remap_qualified_name(
2502 browse_name: &QualifiedName,
2503 mapping: &BTreeMap<u16, u16>,
2504) -> QualifiedName {
2505 QualifiedName::new(
2506 mapping
2507 .get(&browse_name.namespace_index)
2508 .copied()
2509 .unwrap_or(browse_name.namespace_index),
2510 browse_name.name.clone(),
2511 )
2512}
2513
2514fn parse_node_id(value: &str) -> OpcUaResult<NodeId> {
2515 value
2516 .parse::<NodeId>()
2517 .map_err(|error| OpcUaError::InvalidNodeId(error.to_string()))
2518}
2519
2520fn parse_browse_name(value: &str) -> QualifiedName {
2521 if let Some((namespace, name)) = value.split_once(':') {
2522 if let Ok(namespace_index) = namespace.parse::<u16>() {
2523 return QualifiedName::new(namespace_index, name);
2524 }
2525 }
2526 QualifiedName::new(0, value)
2527}
2528
2529fn parse_reference_type(value: &str) -> OpcUaResult<ReferenceTypeId> {
2530 if let Ok(node_id) = parse_node_id(value) {
2531 if let Some(reference_type) = ReferenceTypeId::from_node_id(&node_id) {
2532 return Ok(reference_type);
2533 }
2534 }
2535 match value {
2536 "Organizes" => Ok(ReferenceTypeId::Organizes),
2537 "HasComponent" => Ok(ReferenceTypeId::HasComponent),
2538 "HasProperty" => Ok(ReferenceTypeId::HasProperty),
2539 "HasSubtype" => Ok(ReferenceTypeId::HasSubtype),
2540 "HasTypeDefinition" => Ok(ReferenceTypeId::HasTypeDefinition),
2541 "HasNotifier" => Ok(ReferenceTypeId::HasNotifier),
2542 "HasEventSource" => Ok(ReferenceTypeId::HasEventSource),
2543 other => Err(OpcUaError::Config(format!(
2544 "unsupported reference type '{}'",
2545 other
2546 ))),
2547 }
2548}
2549
2550fn parse_value_element(xml: Option<&str>) -> Option<Value> {
2551 let value_xml = xml?;
2552 for tag in [
2553 "Boolean",
2554 "Double",
2555 "Float",
2556 "Int16",
2557 "UInt16",
2558 "Int32",
2559 "UInt32",
2560 "Int64",
2561 "UInt64",
2562 "String",
2563 "DateTime",
2564 "ByteString",
2565 ] {
2566 if let Some(element) = collect_xml_elements(value_xml, tag).into_iter().next() {
2567 let raw = element.inner.trim();
2568 return match tag {
2569 "Boolean" => raw.parse::<bool>().ok().map(Value::Bool),
2570 "Double" | "Float" => raw.parse::<f64>().ok().map(Value::F64),
2571 "Int16" | "Int32" | "Int64" => raw.parse::<i64>().ok().map(Value::I64),
2572 "UInt16" | "UInt32" | "UInt64" => raw.parse::<u64>().ok().map(Value::U64),
2573 "String" => Some(Value::String(raw.to_string())),
2574 "DateTime" => Some(Value::String(raw.to_string())),
2575 "ByteString" => Some(Value::Bytes(raw.as_bytes().to_vec())),
2576 _ => None,
2577 };
2578 }
2579 }
2580 None
2581}
2582
2583fn parse_access_level(value: Option<&str>) -> AccessLevel {
2584 value
2585 .and_then(|raw| raw.parse::<u8>().ok())
2586 .map(AccessLevel::from_raw)
2587 .unwrap_or(AccessLevel::CURRENT_READ)
2588}
2589
2590fn parse_bool_attr(value: Option<&str>) -> Option<bool> {
2591 value.map(|value| value.eq_ignore_ascii_case("true") || value == "1")
2592}
2593
2594fn parse_u8_attr(value: Option<&str>) -> Option<u8> {
2595 value.and_then(|value| value.parse::<u8>().ok())
2596}
2597
2598fn parse_f64_attr(value: Option<&str>) -> Option<f64> {
2599 value.and_then(|value| value.parse::<f64>().ok())
2600}
2601
2602fn localized_text(text: impl Into<String>) -> LocalizedText {
2603 LocalizedText::new("en-US", text.into())
2604}
2605
2606fn localized_text_owned(text: impl Into<String>) -> LocalizedText {
2607 LocalizedText::new("en-US", text.into())
2608}
2609
2610fn push_unique(values: &mut Vec<String>, value: String) {
2611 if !values.iter().any(|existing| existing == &value) {
2612 values.push(value);
2613 }
2614}
2615
2616fn resolve_path(base_path: Option<&Path>, path: &Path) -> PathBuf {
2617 if path.is_absolute() {
2618 return path.to_path_buf();
2619 }
2620 match base_path.and_then(Path::parent) {
2621 Some(parent) => parent.join(path),
2622 None => path.to_path_buf(),
2623 }
2624}
2625
2626fn required_attr<'a>(element: &'a XmlElement, name: &str) -> OpcUaResult<&'a str> {
2627 element.attr(name).ok_or_else(|| {
2628 OpcUaError::Config(format!(
2629 "NodeSet element '{}' is missing required attribute '{}'",
2630 element.tag, name
2631 ))
2632 })
2633}
2634
2635fn is_standard_node(node_id: &NodeId) -> bool {
2636 node_id.namespace() == 0
2637}
2638
2639fn default_namespace_uri() -> String {
2640 DEFAULT_NAMESPACE_URI.to_string()
2641}
2642
2643fn default_server_name() -> String {
2644 DEFAULT_SERVER_NAME.to_string()
2645}
2646
2647fn default_bind_address() -> String {
2648 "0.0.0.0".to_string()
2649}
2650
2651fn default_port() -> u16 {
2652 4840
2653}
2654
2655fn default_endpoint_path() -> String {
2656 DEFAULT_ENDPOINT_PATH.to_string()
2657}
2658
2659fn default_preset_nodes() -> usize {
2660 100
2661}
2662
2663fn default_preset_base_node() -> u32 {
2664 1000
2665}
2666
2667fn default_true() -> bool {
2668 true
2669}
2670
2671fn default_reference_direction() -> ReferenceDirection {
2672 ReferenceDirection::Forward
2673}
2674
2675#[derive(Debug, Clone)]
2676struct XmlElement {
2677 tag: String,
2678 attrs: BTreeMap<String, String>,
2679 inner: String,
2680}
2681
2682impl XmlElement {
2683 fn attr(&self, name: &str) -> Option<&str> {
2684 self.attrs.get(name).map(String::as_str)
2685 }
2686
2687 fn text_of(&self, tag: &str) -> Option<String> {
2688 collect_xml_elements(&self.inner, tag)
2689 .into_iter()
2690 .next()
2691 .map(|element| element.inner.trim().to_string())
2692 }
2693
2694 fn child_xml(&self, tag: &str) -> Option<&str> {
2695 child_xml_block(&self.inner, tag)
2696 }
2697
2698 fn value_xml(&self) -> Option<&str> {
2699 self.child_xml("Value")
2700 }
2701}
2702
2703fn collect_xml_elements(xml: &str, tag: &str) -> Vec<XmlElement> {
2704 let mut elements = Vec::new();
2705 let mut position = 0;
2706 let open = format!("<{}", tag);
2707 let close = format!("</{}>", tag);
2708
2709 while let Some(relative_start) = xml[position..].find(&open) {
2710 let start = position + relative_start;
2711 let Some(relative_end) = xml[start..].find('>') else {
2712 break;
2713 };
2714 let end = start + relative_end;
2715 let open_tag = &xml[start + 1..end];
2716 let self_closing = open_tag.trim_end().ends_with('/');
2717 let attrs = parse_xml_attributes(
2718 open_tag
2719 .strip_prefix(tag)
2720 .unwrap_or(open_tag)
2721 .trim()
2722 .trim_end_matches('/'),
2723 );
2724
2725 if self_closing {
2726 elements.push(XmlElement {
2727 tag: tag.to_string(),
2728 attrs,
2729 inner: String::new(),
2730 });
2731 position = end + 1;
2732 continue;
2733 }
2734
2735 let search_start = end + 1;
2736 let Some(relative_close_start) = xml[search_start..].find(&close) else {
2737 break;
2738 };
2739 let close_start = search_start + relative_close_start;
2740 elements.push(XmlElement {
2741 tag: tag.to_string(),
2742 attrs,
2743 inner: xml[search_start..close_start].to_string(),
2744 });
2745 position = close_start + close.len();
2746 }
2747
2748 elements
2749}
2750
2751fn child_xml_block<'a>(xml: &'a str, tag: &str) -> Option<&'a str> {
2752 let open = format!("<{}>", tag);
2753 let close = format!("</{}>", tag);
2754 let start = xml.find(&open)? + open.len();
2755 let end = xml[start..].find(&close)? + start;
2756 Some(&xml[start..end])
2757}
2758
2759fn parse_xml_attributes(input: &str) -> BTreeMap<String, String> {
2760 let mut attrs = BTreeMap::new();
2761 let bytes = input.as_bytes();
2762 let mut index = 0;
2763 while index < bytes.len() {
2764 while index < bytes.len() && bytes[index].is_ascii_whitespace() {
2765 index += 1;
2766 }
2767 if index >= bytes.len() {
2768 break;
2769 }
2770 let key_start = index;
2771 while index < bytes.len() && !bytes[index].is_ascii_whitespace() && bytes[index] != b'=' {
2772 index += 1;
2773 }
2774 let key = input[key_start..index].trim();
2775 while index < bytes.len() && (bytes[index].is_ascii_whitespace() || bytes[index] == b'=') {
2776 index += 1;
2777 }
2778 if index >= bytes.len() || bytes[index] != b'"' {
2779 break;
2780 }
2781 index += 1;
2782 let value_start = index;
2783 while index < bytes.len() && bytes[index] != b'"' {
2784 index += 1;
2785 }
2786 if index <= bytes.len() {
2787 attrs.insert(key.to_string(), input[value_start..index].to_string());
2788 }
2789 index += 1;
2790 }
2791 attrs
2792}
2793
2794#[cfg(test)]
2795mod tests {
2796 use super::*;
2797
2798 use tempfile::NamedTempFile;
2799
2800 fn sample_nodeset() -> String {
2801 r#"
2802 <UANodeSet>
2803 <NamespaceUris>
2804 <Uri>urn:example:model</Uri>
2805 </NamespaceUris>
2806 <UAObject NodeId="ns=1;s=Machine" BrowseName="1:Machine">
2807 <DisplayName>Machine</DisplayName>
2808 <References>
2809 <Reference ReferenceType="Organizes">i=85</Reference>
2810 </References>
2811 </UAObject>
2812 <UAVariable NodeId="ns=1;s=Machine.Temperature" BrowseName="1:Temperature" DataType="i=11" AccessLevel="3" Historizing="true">
2813 <DisplayName>Temperature</DisplayName>
2814 <Value><Double>21.5</Double></Value>
2815 <References>
2816 <Reference ReferenceType="HasComponent">ns=1;s=Machine</Reference>
2817 </References>
2818 </UAVariable>
2819 </UANodeSet>
2820 "#
2821 .to_string()
2822 }
2823
2824 #[test]
2825 fn schema_summary_lists_canonical_sections() {
2826 let summary = schema_summary();
2827 assert_eq!(summary.kind, "opcua_simulator");
2828 assert!(summary
2829 .top_level_sections
2830 .iter()
2831 .any(|section| section.name == "nodesets"));
2832 assert!(summary
2833 .top_level_sections
2834 .iter()
2835 .any(|section| section.name == "security_profiles"));
2836 assert!(summary
2837 .top_level_sections
2838 .iter()
2839 .any(|section| section.name == "generated_types"));
2840 }
2841
2842 #[test]
2843 fn imported_nodeset_parses_nodes_and_references() {
2844 let imported = import_nodeset_xml(&sample_nodeset(), None).unwrap();
2845 assert_eq!(imported.local_namespace_table.len(), 2);
2846 assert_eq!(imported.nodes.len(), 2);
2847 assert_eq!(imported.references.len(), 2);
2848 }
2849
2850 #[test]
2851 fn config_compiles_file_backed_session() {
2852 let nodeset = NamedTempFile::new().unwrap();
2853 fs::write(nodeset.path(), sample_nodeset()).unwrap();
2854
2855 let config = OpcUaSimulatorConfig {
2856 transports: BTreeMap::from([(
2857 "main".into(),
2858 TransportDefinition {
2859 protocol: TransportProtocol::OpcTcp,
2860 connection_mode: TransportConnectionMode::Listener,
2861 bind: "127.0.0.1".into(),
2862 port: 4840,
2863 endpoint_path: "/sim".into(),
2864 reverse_connect_target: None,
2865 retry_interval_ms: 5_000,
2866 security_profile: Some("None".into()),
2867 server_name: None,
2868 certificate_path: None,
2869 private_key_path: None,
2870 },
2871 )]),
2872 nodesets: BTreeMap::from([(
2873 "demo".into(),
2874 NodeSetSource::File {
2875 path: nodeset.path().to_path_buf(),
2876 namespace_uri_override: None,
2877 },
2878 )]),
2879 models: BTreeMap::from([(
2880 "machine".into(),
2881 ModelDefinition {
2882 nodesets: vec!["demo".into()],
2883 ..Default::default()
2884 },
2885 )]),
2886 sessions: BTreeMap::from([(
2887 "demo".into(),
2888 SessionDefinition {
2889 transport: "main".into(),
2890 models: vec!["machine".into()],
2891 devices: Vec::new(),
2892 preset: None,
2893 service_name: Some("opcua-demo".into()),
2894 readiness_timeout_ms: Some(1_000),
2895 control: SessionControlConfig::default(),
2896 runtime: SessionRuntimeConfig::default(),
2897 },
2898 )]),
2899 ..Default::default()
2900 };
2901
2902 let compiled = config
2903 .compile_session("demo", Some(nodeset.path()))
2904 .unwrap();
2905 assert_eq!(compiled.session_name, "demo");
2906 assert!(!compiled.catalog.nodes.is_empty());
2907 assert!(!compiled.devices.is_empty());
2908 assert_eq!(compiled.launch.protocol, "opcua");
2909 }
2910
2911 #[test]
2912 fn legacy_preset_generates_ephemeral_model() {
2913 let config = OpcUaSimulatorConfig {
2914 transports: BTreeMap::from([("main".into(), TransportDefinition::default())]),
2915 presets: BTreeMap::from([("legacy".into(), PresetDefinition::default())]),
2916 sessions: BTreeMap::from([(
2917 "legacy".into(),
2918 SessionDefinition {
2919 transport: "main".into(),
2920 preset: Some("legacy".into()),
2921 service_name: Some("legacy-service".into()),
2922 ..Default::default()
2923 },
2924 )]),
2925 ..Default::default()
2926 };
2927
2928 let compiled = config.compile_session("legacy", None).unwrap();
2929 assert!(!compiled.catalog.nodes.is_empty());
2930 assert!(!compiled.devices.is_empty());
2931 }
2932
2933 #[test]
2934 fn generate_types_renders_stable_module() {
2935 let config = OpcUaSimulatorConfig {
2936 transports: BTreeMap::from([("main".into(), TransportDefinition::default())]),
2937 presets: BTreeMap::from([("legacy".into(), PresetDefinition::default())]),
2938 sessions: BTreeMap::from([(
2939 "legacy".into(),
2940 SessionDefinition {
2941 transport: "main".into(),
2942 preset: Some("legacy".into()),
2943 service_name: Some("legacy-service".into()),
2944 ..Default::default()
2945 },
2946 )]),
2947 generated_types: GeneratedTypesConfig {
2948 enabled: true,
2949 module_name: Some("legacy_types".into()),
2950 },
2951 ..Default::default()
2952 };
2953
2954 let generated = generate_types(&config, "legacy", None).unwrap();
2955 assert_eq!(generated.module_name, "legacy_types");
2956 assert!(generated.source.contains("pub mod legacy_types"));
2957 assert!(generated
2958 .manifest_json
2959 .contains("\"module_name\": \"legacy_types\""));
2960 }
2961
2962 #[test]
2963 fn compile_session_reports_cache_hits() {
2964 let session_name = format!("legacy-{}", uuid::Uuid::new_v4());
2965 let config = OpcUaSimulatorConfig {
2966 transports: BTreeMap::from([("main".into(), TransportDefinition::default())]),
2967 presets: BTreeMap::from([("legacy".into(), PresetDefinition::default())]),
2968 sessions: BTreeMap::from([(
2969 session_name.clone(),
2970 SessionDefinition {
2971 transport: "main".into(),
2972 preset: Some("legacy".into()),
2973 service_name: Some("legacy-service".into()),
2974 ..Default::default()
2975 },
2976 )]),
2977 ..Default::default()
2978 };
2979
2980 let (_, first) = compile_session_with_report(&config, &session_name, None).unwrap();
2981 let (_, second) = compile_session_with_report(&config, &session_name, None).unwrap();
2982 assert!(!first.compilation_hit);
2983 assert!(second.compilation_hit);
2984 }
2985}