1use std::collections::{BTreeMap, BTreeSet};
4use std::net::SocketAddr;
5use std::path::Path;
6use std::sync::Arc;
7
8use serde::{Deserialize, Serialize};
9use serde_json::{json, Value as JsonValue};
10
11use mabi_runtime::{ProtocolLaunchSpec, RuntimeExtensions};
12
13use crate::behavior::BehaviorLayer;
14use crate::error::{ModbusError, ModbusResult};
15use crate::fault_injection::{
16 ExtraDataMode, FaultConfig, FaultInjectionConfig, FaultTarget, FaultTypeConfig,
17 PartialFrameMode, TruncationMode,
18};
19use crate::profile::{DatastoreKind, GeneratedProfilePreset, SimulatorProfile};
20use crate::rtu::{PerformancePreset as RtuPerformancePreset, RtuServerConfig};
21use crate::tcp::PerformancePreset as TcpPerformancePreset;
22use mabi_core::types::{DataType, ModbusRegisterType};
23
24#[derive(Debug, Clone, Default, Serialize, Deserialize)]
26pub struct ModbusSimulatorConfig {
27 #[serde(default)]
28 pub defaults: SimulatorDefaults,
29 #[serde(default)]
30 pub transports: BTreeMap<String, TransportDefinition>,
31 #[serde(default)]
32 pub datastores: BTreeMap<String, DatastoreDefinition>,
33 #[serde(default)]
34 pub devices: BTreeMap<String, DeviceBundleDefinition>,
35 #[serde(default)]
36 pub sessions: BTreeMap<String, SessionDefinition>,
37 #[serde(default)]
38 pub presets: BTreeMap<String, GeneratedPresetDefinition>,
39 #[serde(default)]
40 pub actions: BTreeMap<String, ActionDefinition>,
41 #[serde(default)]
42 pub behaviors: BTreeMap<String, BehaviorDefinition>,
43 #[serde(default)]
44 pub response_profiles: BTreeMap<String, ResponseProfileDefinition>,
45}
46
47impl ModbusSimulatorConfig {
48 pub fn from_path(path: &Path) -> ModbusResult<Self> {
50 let content = std::fs::read_to_string(path)?;
51 let extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or("");
52 Self::from_str_with_format(&content, extension)
53 }
54
55 pub fn from_str_with_format(content: &str, format: &str) -> ModbusResult<Self> {
57 let parsed: Self = match format {
58 "yaml" | "yml" => serde_yaml::from_str(content)
59 .map_err(|error| ModbusError::Config(format!("invalid YAML config: {}", error)))?,
60 "json" => serde_json::from_str(content)
61 .map_err(|error| ModbusError::Config(format!("invalid JSON config: {}", error)))?,
62 "toml" => toml::from_str(content)
63 .map_err(|error| ModbusError::Config(format!("invalid TOML config: {}", error)))?,
64 other => {
65 return Err(ModbusError::Config(format!(
66 "unsupported config format: {}",
67 other
68 )))
69 }
70 };
71 parsed.validate()?;
72 Ok(parsed)
73 }
74
75 pub fn validate(&self) -> ModbusResult<()> {
77 if self.sessions.is_empty() {
78 return Err(ModbusError::Config(
79 "simulator config must define at least one session".into(),
80 ));
81 }
82
83 for (name, session) in &self.sessions {
84 if !self.transports.contains_key(&session.transport) {
85 return Err(ModbusError::Config(format!(
86 "session '{}' references unknown transport '{}'",
87 name, session.transport
88 )));
89 }
90 if session.devices.is_empty() && session.preset.is_none() {
91 return Err(ModbusError::Config(format!(
92 "session '{}' must reference at least one device bundle or preset",
93 name
94 )));
95 }
96 for device in &session.devices {
97 if !self.devices.contains_key(device) {
98 return Err(ModbusError::Config(format!(
99 "session '{}' references unknown device bundle '{}'",
100 name, device
101 )));
102 }
103 }
104 if let Some(preset) = &session.preset {
105 if !self.presets.contains_key(preset) {
106 return Err(ModbusError::Config(format!(
107 "session '{}' references unknown preset '{}'",
108 name, preset
109 )));
110 }
111 }
112 if let Some(active) = &session.active_fault_preset {
113 if !session.fault_presets.contains_key(active) {
114 return Err(ModbusError::Config(format!(
115 "session '{}' references unknown fault preset '{}'",
116 name, active
117 )));
118 }
119 }
120 if let Some(active) = &session.active_response_profile {
121 if !self.response_profiles.contains_key(active) {
122 return Err(ModbusError::Config(format!(
123 "session '{}' references unknown response profile '{}'",
124 name, active
125 )));
126 }
127 }
128 if let Some(active) = &session.active_behavior_set {
129 if !session.behavior_sets.contains_key(active) {
130 return Err(ModbusError::Config(format!(
131 "session '{}' references unknown behavior set '{}'",
132 name, active
133 )));
134 }
135 }
136
137 let mut unit_ids = BTreeSet::new();
138 let compiled_profile = self.compile_profile(session)?;
139 for unit in &compiled_profile.units {
140 if !unit_ids.insert(unit.unit_id) {
141 return Err(ModbusError::Config(format!(
142 "session '{}' contains duplicate unit id {}",
143 name, unit.unit_id
144 )));
145 }
146
147 let mut point_ids = BTreeSet::new();
148 for point in &unit.points {
149 if !point_ids.insert(point.id.clone()) {
150 return Err(ModbusError::Config(format!(
151 "session '{}' contains duplicate point id '{}' in unit {}",
152 name, point.id, unit.unit_id
153 )));
154 }
155 }
156 }
157
158 for device_name in &session.devices {
159 let bundle = self.devices.get(device_name).ok_or_else(|| {
160 ModbusError::Config(format!("unknown device bundle '{}'", device_name))
161 })?;
162 for unit in &bundle.units {
163 let point_ids = unit
164 .points
165 .iter()
166 .map(|point| point.id.as_str())
167 .collect::<BTreeSet<_>>();
168 for binding in &unit.action_bindings {
169 if !point_ids.contains(binding.point_id.as_str()) {
170 return Err(ModbusError::Config(format!(
171 "unit {} references unknown point '{}' in action bindings",
172 unit.unit_id, binding.point_id
173 )));
174 }
175 for action in &binding.bindings {
176 if !self.actions.contains_key(&action.action) {
177 return Err(ModbusError::Config(format!(
178 "unit {} references unknown action '{}'",
179 unit.unit_id, action.action
180 )));
181 }
182 }
183 }
184 }
185 }
186
187 for (set_name, behavior_set) in &session.behavior_sets {
188 for behavior_name in &behavior_set.behaviors {
189 let behavior = self.behaviors.get(behavior_name).ok_or_else(|| {
190 ModbusError::Config(format!(
191 "session '{}' behavior set '{}' references unknown behavior '{}'",
192 name, set_name, behavior_name
193 ))
194 })?;
195 for action_name in &behavior.actions {
196 if !self.actions.contains_key(action_name) {
197 return Err(ModbusError::Config(format!(
198 "behavior '{}' references unknown action '{}'",
199 behavior_name, action_name
200 )));
201 }
202 }
203
204 let matches = matching_behavior_targets(behavior, &compiled_profile);
205 if matches.is_empty() {
206 return Err(ModbusError::Config(format!(
207 "behavior '{}' does not match any point in session '{}'",
208 behavior_name, name
209 )));
210 }
211 }
212 }
213 }
214
215 Ok(())
216 }
217
218 pub fn compile_session(&self, name: &str) -> ModbusResult<CompiledModbusSession> {
220 let session = self
221 .sessions
222 .get(name)
223 .ok_or_else(|| ModbusError::Config(format!("unknown session '{}'", name)))?;
224 let profile = self.compile_profile(session)?;
225 let transport = self.transports.get(&session.transport).ok_or_else(|| {
226 ModbusError::Config(format!(
227 "session '{}' references unknown transport '{}'",
228 name, session.transport
229 ))
230 })?;
231
232 let launch = ProtocolLaunchSpec {
233 protocol: "modbus".into(),
234 name: Some(
235 session
236 .service_name
237 .clone()
238 .unwrap_or_else(|| name.to_string()),
239 ),
240 config: serde_json::to_value(ModbusServiceLaunchConfig::from_session(
241 &self.defaults,
242 transport,
243 profile.clone(),
244 ))
245 .map_err(|error| {
246 ModbusError::Config(format!("failed to encode session launch config: {}", error))
247 })?,
248 };
249
250 let metadata = self.compile_session_metadata(session, &profile)?;
251
252 Ok(CompiledModbusSession {
253 session_name: name.to_string(),
254 launch,
255 transport_kind: transport.kind(),
256 profile,
257 trace: session.trace.resolved(&self.defaults),
258 reset: session.reset.clone(),
259 control: session.control.clone(),
260 fault_presets: session.fault_presets.clone(),
261 active_fault_preset: session.active_fault_preset.clone(),
262 response_profiles: self.response_profiles.clone(),
263 active_response_profile: session.active_response_profile.clone(),
264 actions: self.actions.clone(),
265 behaviors: self.behaviors.clone(),
266 behavior_sets: session.behavior_sets.clone(),
267 active_behavior_set: session.active_behavior_set.clone(),
268 point_catalog: metadata.point_catalog,
269 datastore_policies: metadata.datastore_policies,
270 action_binding_summaries: metadata.action_bindings,
271 behavior_binding_summaries: metadata.behavior_bindings,
272 compiled_behavior_bindings: metadata.compiled_behavior_bindings,
273 readiness_timeout_ms: session
274 .readiness_timeout_ms
275 .or(self.defaults.readiness_timeout_ms),
276 })
277 }
278
279 pub fn inspect_summary(&self) -> ModbusConfigSummary {
281 ModbusConfigSummary {
282 transports: self.transports.keys().cloned().collect(),
283 datastores: self.datastores.keys().cloned().collect(),
284 devices: self.devices.keys().cloned().collect(),
285 sessions: self
286 .sessions
287 .iter()
288 .map(|(name, session)| SessionSummary {
289 name: name.clone(),
290 transport: session.transport.clone(),
291 devices: session.devices.clone(),
292 preset: session.preset.clone(),
293 active_fault_preset: session.active_fault_preset.clone(),
294 active_response_profile: session.active_response_profile.clone(),
295 active_behavior_set: session.active_behavior_set.clone(),
296 })
297 .collect(),
298 presets: self.presets.keys().cloned().collect(),
299 actions: self.actions.keys().cloned().collect(),
300 behaviors: self.behaviors.keys().cloned().collect(),
301 response_profiles: self.response_profiles.keys().cloned().collect(),
302 }
303 }
304
305 fn compile_profile(&self, session: &SessionDefinition) -> ModbusResult<SimulatorProfile> {
306 let mut profile = if let Some(preset_name) = &session.preset {
307 self.presets
308 .get(preset_name)
309 .ok_or_else(|| ModbusError::Config(format!("unknown preset '{}'", preset_name)))?
310 .build(&self.datastores)?
311 } else {
312 SimulatorProfile::new()
313 };
314
315 for device_name in &session.devices {
316 let bundle = self.devices.get(device_name).ok_or_else(|| {
317 ModbusError::Config(format!("unknown device bundle '{}'", device_name))
318 })?;
319 let compiled = bundle.compile(&self.datastores)?;
320 profile.broadcast_enabled |= compiled.broadcast_enabled;
321 profile.units.extend(compiled.units);
322 }
323
324 Ok(profile)
325 }
326
327 fn compile_session_metadata(
328 &self,
329 session: &SessionDefinition,
330 profile: &SimulatorProfile,
331 ) -> ModbusResult<CompiledSessionMetadata> {
332 let mut point_catalog = BTreeMap::new();
333 for unit in &profile.units {
334 let device_id = format!("modbus-{}", unit.unit_id);
335 for point in &unit.points {
336 point_catalog.insert(
337 point_catalog_key(&device_id, &point.id),
338 CompiledPointMetadata {
339 device_id: device_id.clone(),
340 point_id: point.id.clone(),
341 source_datastore: None,
342 read_only: matches!(
343 point.register_type,
344 ModbusRegisterType::InputRegister | ModbusRegisterType::DiscreteInput
345 ),
346 invalid: false,
347 action_bindings: Vec::new(),
348 behavior_bindings: Vec::new(),
349 },
350 );
351 }
352 }
353
354 let mut datastore_policies = BTreeMap::new();
355 let mut action_bindings = Vec::new();
356 let mut behavior_bindings = Vec::new();
357 let mut compiled_behavior_bindings = Vec::new();
358
359 if let Some(preset_name) = &session.preset {
360 if let Some(datastore) = self
361 .presets
362 .get(preset_name)
363 .and_then(|preset| preset.datastore.as_ref())
364 {
365 let datastore_name = datastore.reference_name();
366 let resolved = datastore.resolve(&self.datastores)?;
367 datastore_policies.insert(
368 datastore_name
369 .clone()
370 .unwrap_or_else(|| format!("preset:{}", preset_name)),
371 resolved.policy_summary(datastore_name.as_deref()),
372 );
373
374 for unit in &profile.units {
375 let device_id = format!("modbus-{}", unit.unit_id);
376 for point in &unit.points {
377 let key = point_catalog_key(&device_id, &point.id);
378 if let Some(entry) = point_catalog.get_mut(&key) {
379 entry.source_datastore = datastore_name.clone();
380 entry.read_only = entry.read_only
381 || resolved.is_read_only(point.register_type, point.address);
382 entry.invalid = resolved.is_invalid(point.register_type, point.address);
383 }
384 }
385 }
386 }
387 }
388
389 for device_name in &session.devices {
390 let bundle = self.devices.get(device_name).ok_or_else(|| {
391 ModbusError::Config(format!("unknown device bundle '{}'", device_name))
392 })?;
393 for unit in &bundle.units {
394 let device_id = format!("modbus-{}", unit.unit_id);
395 let datastore_name = unit
396 .datastore
397 .as_ref()
398 .and_then(DatastoreSelector::reference_name);
399 let datastore = match &unit.datastore {
400 Some(selector) => selector.resolve(&self.datastores)?,
401 None => DatastoreDefinition::default(),
402 };
403 let summary_name = datastore_name
404 .clone()
405 .unwrap_or_else(|| format!("{}/unit-{}", device_name, unit.unit_id));
406 datastore_policies.insert(
407 summary_name,
408 datastore.policy_summary(datastore_name.as_deref()),
409 );
410
411 let binding_map = unit.binding_summary_map();
412 let binding_defs = unit.binding_definition_map();
413 for point in &unit.points {
414 let key = point_catalog_key(&device_id, &point.id);
415 let actions = binding_map.get(&point.id).cloned().unwrap_or_default();
416 if let Some(entry) = point_catalog.get_mut(&key) {
417 entry.source_datastore = datastore_name.clone();
418 entry.read_only = entry.read_only
419 || datastore.is_read_only(point.register_type, point.address);
420 entry.invalid = datastore.is_invalid(point.register_type, point.address);
421 entry.action_bindings = actions.clone();
422 entry.behavior_bindings = actions.clone();
423 }
424 if !actions.is_empty() {
425 action_bindings.push(ActionBindingSummary {
426 device_id: device_id.clone(),
427 point_id: point.id.clone(),
428 bindings: actions,
429 });
430 }
431 if let Some(definitions) = binding_defs.get(&point.id) {
432 for definition in definitions {
433 compiled_behavior_bindings.push(CompiledBehaviorBinding {
434 name: definition.action.clone(),
435 behavior_set: "__compat".to_string(),
436 device_id: device_id.clone(),
437 point_id: point.id.clone(),
438 trigger: definition.trigger,
439 condition: None,
440 interval_ms: None,
441 actions: vec![definition.action.clone()],
442 });
443 }
444 }
445 }
446 }
447 }
448
449 for (set_name, behavior_set) in &session.behavior_sets {
450 for behavior_name in &behavior_set.behaviors {
451 let behavior = self.behaviors.get(behavior_name).ok_or_else(|| {
452 ModbusError::Config(format!("unknown behavior '{}'", behavior_name))
453 })?;
454 for target in matching_behavior_targets(behavior, profile) {
455 let key = point_catalog_key(&target.device_id, &target.point_id);
456 if let Some(entry) = point_catalog.get_mut(&key) {
457 entry
458 .behavior_bindings
459 .push(format!("{}@{}", behavior_name, set_name));
460 }
461 behavior_bindings.push(BehaviorBindingSummary {
462 device_id: target.device_id.clone(),
463 point_id: target.point_id.clone(),
464 behavior: behavior_name.clone(),
465 behavior_set: set_name.clone(),
466 trigger: behavior.trigger,
467 });
468 compiled_behavior_bindings.push(CompiledBehaviorBinding {
469 name: behavior_name.clone(),
470 behavior_set: set_name.clone(),
471 device_id: target.device_id,
472 point_id: target.point_id,
473 trigger: behavior.trigger,
474 condition: behavior.condition.clone(),
475 interval_ms: behavior.interval_ms,
476 actions: behavior.actions.clone(),
477 });
478 }
479 }
480 }
481
482 Ok(CompiledSessionMetadata {
483 point_catalog,
484 datastore_policies: datastore_policies.into_values().collect(),
485 action_bindings,
486 behavior_bindings,
487 compiled_behavior_bindings,
488 })
489 }
490}
491
492#[derive(Debug, Clone, Serialize, Deserialize)]
494pub struct SimulatorDefaults {
495 #[serde(default = "default_trace_capacity")]
496 pub trace_capacity: usize,
497 #[serde(default)]
498 pub tcp_performance_preset: TcpPerformancePreset,
499 #[serde(default)]
500 pub rtu_performance_preset: RtuPerformancePreset,
501 #[serde(default)]
502 pub readiness_timeout_ms: Option<u64>,
503}
504
505impl Default for SimulatorDefaults {
506 fn default() -> Self {
507 Self {
508 trace_capacity: default_trace_capacity(),
509 tcp_performance_preset: TcpPerformancePreset::Default,
510 rtu_performance_preset: RtuPerformancePreset::Default,
511 readiness_timeout_ms: None,
512 }
513 }
514}
515
516fn default_trace_capacity() -> usize {
517 256
518}
519
520#[derive(Debug, Clone, Serialize, Deserialize)]
522#[serde(tag = "kind", rename_all = "snake_case")]
523pub enum TransportDefinition {
524 Tcp {
525 #[serde(default = "default_bind")]
526 bind: String,
527 #[serde(default = "default_tcp_port")]
528 port: u16,
529 #[serde(default)]
530 performance_preset: Option<TcpPerformancePreset>,
531 },
532 Rtu {
533 #[serde(default)]
534 config: RtuServerConfig,
535 },
536}
537
538impl TransportDefinition {
539 fn kind(&self) -> CompiledTransportKind {
540 match self {
541 Self::Tcp { .. } => CompiledTransportKind::Tcp,
542 Self::Rtu { .. } => CompiledTransportKind::Rtu,
543 }
544 }
545}
546
547fn default_bind() -> String {
548 "0.0.0.0".to_string()
549}
550
551fn default_tcp_port() -> u16 {
552 502
553}
554
555#[derive(Debug, Clone, Serialize, Deserialize)]
557pub struct DatastoreAddressRange {
558 pub register_type: ModbusRegisterType,
559 pub start: u16,
560 pub quantity: u16,
561}
562
563impl DatastoreAddressRange {
564 fn matches(&self, register_type: ModbusRegisterType, address: u16) -> bool {
565 self.register_type == register_type
566 && address >= self.start
567 && address < self.start.saturating_add(self.quantity)
568 }
569}
570
571#[derive(Debug, Clone, Serialize, Deserialize)]
573pub struct DatastoreTypedBlock {
574 pub register_type: ModbusRegisterType,
575 pub start: u16,
576 pub quantity: u16,
577 pub data_type: DataType,
578}
579
580#[derive(Debug, Clone, Serialize, Deserialize)]
582pub struct DatastoreRepeatPolicy {
583 pub every: u16,
584 pub count: u16,
585}
586
587#[derive(Debug, Clone, Serialize, Deserialize)]
589#[serde(rename_all = "snake_case")]
590pub enum DatastoreInitialization {
591 Zero,
592 One,
593 Max,
594 Preserve,
595}
596
597#[derive(Debug, Clone, Serialize, Deserialize)]
599pub struct DatastoreDefinition {
600 #[serde(flatten)]
601 pub kind: DatastoreKind,
602 #[serde(default)]
603 pub invalid_ranges: Vec<DatastoreAddressRange>,
604 #[serde(default)]
605 pub readonly_ranges: Vec<DatastoreAddressRange>,
606 #[serde(default)]
607 pub typed_blocks: Vec<DatastoreTypedBlock>,
608 #[serde(default)]
609 pub default_value: Option<JsonValue>,
610 #[serde(default)]
611 pub repeat: Option<DatastoreRepeatPolicy>,
612 #[serde(default)]
613 pub initialization: Option<DatastoreInitialization>,
614}
615
616impl Default for DatastoreDefinition {
617 fn default() -> Self {
618 Self {
619 kind: DatastoreKind::default(),
620 invalid_ranges: Vec::new(),
621 readonly_ranges: Vec::new(),
622 typed_blocks: Vec::new(),
623 default_value: None,
624 repeat: None,
625 initialization: None,
626 }
627 }
628}
629
630impl From<DatastoreKind> for DatastoreDefinition {
631 fn from(value: DatastoreKind) -> Self {
632 Self {
633 kind: value,
634 ..Default::default()
635 }
636 }
637}
638
639impl DatastoreDefinition {
640 fn is_invalid(&self, register_type: ModbusRegisterType, address: u16) -> bool {
641 self.invalid_ranges
642 .iter()
643 .any(|range| range.matches(register_type, address))
644 }
645
646 fn is_read_only(&self, register_type: ModbusRegisterType, address: u16) -> bool {
647 matches!(
648 register_type,
649 ModbusRegisterType::InputRegister | ModbusRegisterType::DiscreteInput
650 ) || self
651 .readonly_ranges
652 .iter()
653 .any(|range| range.matches(register_type, address))
654 }
655
656 fn policy_summary(&self, name: Option<&str>) -> DatastorePolicySummary {
657 DatastorePolicySummary {
658 name: name.unwrap_or("inline").to_string(),
659 kind: match self.kind {
660 DatastoreKind::Dense { .. } => "dense".to_string(),
661 DatastoreKind::Sparse { .. } => "sparse".to_string(),
662 },
663 invalid_ranges: self.invalid_ranges.len(),
664 readonly_ranges: self.readonly_ranges.len(),
665 typed_blocks: self.typed_blocks.len(),
666 has_default_value: self.default_value.is_some(),
667 repeat: self.repeat.clone(),
668 initialization: self
669 .initialization
670 .as_ref()
671 .map(|value| format!("{:?}", value).to_lowercase()),
672 }
673 }
674}
675
676#[derive(Debug, Clone, Serialize, Deserialize)]
678#[serde(untagged)]
679pub enum DatastoreSelector {
680 Named(String),
681 Inline(DatastoreDefinition),
682}
683
684impl DatastoreSelector {
685 fn resolve(
686 &self,
687 datastores: &BTreeMap<String, DatastoreDefinition>,
688 ) -> ModbusResult<DatastoreDefinition> {
689 match self {
690 Self::Named(name) => datastores
691 .get(name)
692 .cloned()
693 .ok_or_else(|| ModbusError::Config(format!("unknown datastore '{}'", name))),
694 Self::Inline(datastore) => Ok(datastore.clone()),
695 }
696 }
697
698 fn reference_name(&self) -> Option<String> {
699 match self {
700 Self::Named(name) => Some(name.clone()),
701 Self::Inline(_) => None,
702 }
703 }
704}
705
706#[derive(Debug, Clone, Serialize, Deserialize)]
708pub struct BehaviorTarget {
709 #[serde(default)]
710 pub device_id: Option<String>,
711 #[serde(default)]
712 pub unit_id: Option<u8>,
713 pub point_id: String,
714}
715
716#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
718#[serde(rename_all = "snake_case")]
719pub enum BehaviorConditionOperator {
720 Eq,
721 Ne,
722 Gt,
723 Gte,
724 Lt,
725 Lte,
726 Changed,
727}
728
729#[derive(Debug, Clone, Serialize, Deserialize)]
731pub struct BehaviorCondition {
732 pub operator: BehaviorConditionOperator,
733 #[serde(default)]
734 pub value: Option<JsonValue>,
735}
736
737#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
739#[serde(rename_all = "snake_case")]
740pub enum BehaviorTrigger {
741 OnRead,
742 #[default]
743 OnWrite,
744 OnInterval,
745 #[serde(alias = "on_boot")]
746 OnStartup,
747 OnReset,
748}
749
750impl BehaviorTrigger {
751 fn as_str(self) -> &'static str {
752 match self {
753 Self::OnRead => "on_read",
754 Self::OnWrite => "on_write",
755 Self::OnInterval => "on_interval",
756 Self::OnStartup => "on_startup",
757 Self::OnReset => "on_reset",
758 }
759 }
760}
761
762pub type ActionTrigger = BehaviorTrigger;
764
765#[derive(Debug, Clone, Serialize, Deserialize)]
767#[serde(tag = "kind", rename_all = "snake_case")]
768pub enum ActionDefinition {
769 SetValue {
770 value: JsonValue,
771 },
772 CopyToPoint {
773 target_point_id: String,
774 },
775 Clamp {
776 min: f64,
777 max: f64,
778 },
779 Mirror {
780 target_point_id: String,
781 },
782 Scale {
783 factor: f64,
784 #[serde(default)]
785 offset: f64,
786 },
787 Offset {
788 value: f64,
789 },
790 Map {
791 mapping: BTreeMap<String, JsonValue>,
792 #[serde(default)]
793 default: Option<JsonValue>,
794 },
795 MaskBits {
796 #[serde(default)]
797 and_mask: Option<u64>,
798 #[serde(default)]
799 or_mask: Option<u64>,
800 },
801 MarkInvalid,
802 ClearInvalid,
803 Latch,
804 Pulse {
805 duration_ms: u64,
806 },
807 Rotate {
808 values: Vec<JsonValue>,
809 },
810}
811
812#[derive(Debug, Clone, Serialize, Deserialize)]
814pub struct BehaviorDefinition {
815 pub target: BehaviorTarget,
816 #[serde(default)]
817 pub trigger: BehaviorTrigger,
818 #[serde(default)]
819 pub condition: Option<BehaviorCondition>,
820 #[serde(default)]
821 pub interval_ms: Option<u64>,
822 #[serde(default)]
823 pub actions: Vec<String>,
824}
825
826#[derive(Debug, Clone, Default, Serialize, Deserialize)]
828pub struct BehaviorSetDefinition {
829 #[serde(default)]
830 pub behaviors: Vec<String>,
831}
832
833#[derive(Debug, Clone, Serialize, Deserialize)]
835pub struct ActionBindingDefinition {
836 pub action: String,
837 #[serde(default)]
838 pub trigger: BehaviorTrigger,
839}
840
841impl ActionBindingDefinition {
842 fn summary(&self) -> String {
843 format!("{}@{}", self.action, self.trigger.as_str())
844 }
845}
846
847#[derive(Debug, Clone, Default, Serialize, Deserialize)]
849pub struct PointActionBinding {
850 pub point_id: String,
851 #[serde(default)]
852 pub bindings: Vec<ActionBindingDefinition>,
853}
854
855#[derive(Debug, Clone, Default, Serialize, Deserialize)]
857pub struct ResponseProfileDefinition {
858 #[serde(default)]
859 pub delay_ms: Option<u64>,
860 #[serde(default)]
861 pub exception_code: Option<u8>,
862 #[serde(default)]
863 pub split_response: Option<SplitResponseDefinition>,
864 #[serde(default)]
865 pub partial_response: Option<PartialResponseDefinition>,
866 #[serde(default)]
867 pub silent_drop: bool,
868 #[serde(default)]
869 pub malformed_response: Option<MalformedResponseDefinition>,
870}
871
872impl ResponseProfileDefinition {
873 fn to_fault_injection_config(
874 &self,
875 transport_kind: CompiledTransportKind,
876 ) -> Option<FaultInjectionConfig> {
877 let mut config = FaultInjectionConfig::new();
878
879 if let Some(delay_ms) = self.delay_ms {
880 config = config.with_fault(FaultConfig::delayed_response(
881 std::time::Duration::from_millis(delay_ms),
882 std::time::Duration::ZERO,
883 FaultTarget::new(),
884 ));
885 }
886 if let Some(exception_code) = self.exception_code {
887 config = config.with_fault(FaultConfig::exception_injection(
888 exception_code,
889 FaultTarget::new(),
890 ));
891 }
892 if self.silent_drop {
893 config = config.with_fault(FaultConfig::no_response(FaultTarget::new()));
894 }
895 if let Some(partial) = &self.partial_response {
896 config = config.with_fault(partial.to_fault_config(transport_kind));
897 }
898 if let Some(split) = &self.split_response {
899 config = config.with_fault(split.to_fault_config(transport_kind));
900 }
901 if let Some(malformed) = &self.malformed_response {
902 malformed.append_faults(transport_kind, &mut config);
903 }
904
905 if config.faults.is_empty() {
906 None
907 } else {
908 Some(config)
909 }
910 }
911}
912
913#[derive(Debug, Clone, Serialize, Deserialize)]
914pub struct SplitResponseDefinition {
915 pub first_chunk_bytes: usize,
916}
917
918impl SplitResponseDefinition {
919 fn to_fault_config(&self, transport_kind: CompiledTransportKind) -> FaultConfig {
920 match transport_kind {
921 CompiledTransportKind::Tcp => FaultConfig {
922 fault_type: crate::fault_injection::FaultType::TruncatedResponse,
923 target: FaultTarget::new(),
924 config: FaultTypeConfig {
925 truncation_mode: Some(TruncationMode::FixedBytes),
926 truncation_bytes: Some(self.first_chunk_bytes),
927 ..Default::default()
928 },
929 },
930 CompiledTransportKind::Rtu => FaultConfig {
931 fault_type: crate::fault_injection::FaultType::PartialFrame,
932 target: FaultTarget::new(),
933 config: FaultTypeConfig {
934 partial_mode: Some(PartialFrameMode::FixedCount),
935 partial_bytes: Some(self.first_chunk_bytes),
936 ..Default::default()
937 },
938 },
939 }
940 }
941}
942
943#[derive(Debug, Clone, Serialize, Deserialize)]
944pub struct PartialResponseDefinition {
945 #[serde(default)]
946 pub bytes: Option<usize>,
947 #[serde(default)]
948 pub percentage: Option<f64>,
949}
950
951impl PartialResponseDefinition {
952 fn to_fault_config(&self, transport_kind: CompiledTransportKind) -> FaultConfig {
953 match transport_kind {
954 CompiledTransportKind::Tcp => FaultConfig {
955 fault_type: crate::fault_injection::FaultType::TruncatedResponse,
956 target: FaultTarget::new(),
957 config: FaultTypeConfig {
958 truncation_mode: Some(if self.bytes.is_some() {
959 TruncationMode::FixedBytes
960 } else {
961 TruncationMode::Percentage
962 }),
963 truncation_bytes: self.bytes,
964 truncation_percentage: self.percentage,
965 ..Default::default()
966 },
967 },
968 CompiledTransportKind::Rtu => FaultConfig {
969 fault_type: crate::fault_injection::FaultType::PartialFrame,
970 target: FaultTarget::new(),
971 config: FaultTypeConfig {
972 partial_mode: Some(if self.bytes.is_some() {
973 PartialFrameMode::FixedCount
974 } else {
975 PartialFrameMode::Percentage
976 }),
977 partial_bytes: self.bytes,
978 partial_percentage: self.percentage,
979 ..Default::default()
980 },
981 },
982 }
983 }
984}
985
986#[derive(Debug, Clone, Default, Serialize, Deserialize)]
987pub struct MalformedResponseDefinition {
988 #[serde(default)]
989 pub truncate_bytes: Option<usize>,
990 #[serde(default)]
991 pub append_bytes: Option<Vec<u8>>,
992 #[serde(default)]
993 pub append_random_count: Option<usize>,
994 #[serde(default)]
995 pub header_only: bool,
996}
997
998impl MalformedResponseDefinition {
999 fn append_faults(
1000 &self,
1001 _transport_kind: CompiledTransportKind,
1002 config: &mut FaultInjectionConfig,
1003 ) {
1004 if self.header_only {
1005 config.faults.push(FaultConfig {
1006 fault_type: crate::fault_injection::FaultType::TruncatedResponse,
1007 target: FaultTarget::new(),
1008 config: FaultTypeConfig {
1009 truncation_mode: Some(TruncationMode::HeaderOnly),
1010 ..Default::default()
1011 },
1012 });
1013 }
1014
1015 if let Some(bytes) = self.truncate_bytes {
1016 config.faults.push(FaultConfig {
1017 fault_type: crate::fault_injection::FaultType::TruncatedResponse,
1018 target: FaultTarget::new(),
1019 config: FaultTypeConfig {
1020 truncation_mode: Some(TruncationMode::RemoveLastN),
1021 truncation_bytes: Some(bytes),
1022 ..Default::default()
1023 },
1024 });
1025 }
1026
1027 if let Some(bytes) = &self.append_bytes {
1028 config.faults.push(FaultConfig {
1029 fault_type: crate::fault_injection::FaultType::ExtraData,
1030 target: FaultTarget::new(),
1031 config: FaultTypeConfig {
1032 extra_data_mode: Some(ExtraDataMode::AppendBytes),
1033 extra_bytes: Some(bytes.clone()),
1034 extra_count: Some(bytes.len()),
1035 ..Default::default()
1036 },
1037 });
1038 }
1039
1040 if let Some(count) = self.append_random_count {
1041 config.faults.push(FaultConfig::extra_data(
1042 ExtraDataMode::AppendRandom,
1043 count,
1044 FaultTarget::new(),
1045 ));
1046 }
1047 }
1048}
1049
1050#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1052pub struct DeviceBundleDefinition {
1053 #[serde(default = "default_true")]
1054 pub broadcast_enabled: bool,
1055 #[serde(default)]
1056 pub units: Vec<UnitDefinition>,
1057}
1058
1059impl DeviceBundleDefinition {
1060 fn compile(
1061 &self,
1062 datastores: &BTreeMap<String, DatastoreDefinition>,
1063 ) -> ModbusResult<SimulatorProfile> {
1064 let mut profile = SimulatorProfile::new();
1065 profile.broadcast_enabled = self.broadcast_enabled;
1066 for unit in &self.units {
1067 profile.units.push(unit.compile(datastores)?);
1068 }
1069 Ok(profile)
1070 }
1071}
1072
1073#[derive(Debug, Clone, Serialize, Deserialize)]
1075pub struct UnitDefinition {
1076 pub unit_id: u8,
1077 pub name: String,
1078 #[serde(default)]
1079 pub datastore: Option<DatastoreSelector>,
1080 #[serde(default)]
1081 pub points: Vec<crate::profile::PointProfile>,
1082 #[serde(default)]
1083 pub response_delay_ms: u64,
1084 #[serde(default)]
1085 pub word_order: crate::types::WordOrder,
1086 #[serde(default = "default_true")]
1087 pub broadcast_enabled: bool,
1088 #[serde(default)]
1089 pub action_bindings: Vec<PointActionBinding>,
1090 #[serde(default, skip_serializing_if = "mabi_core::tags::Tags::is_empty")]
1091 pub tags: mabi_core::tags::Tags,
1092}
1093
1094impl UnitDefinition {
1095 fn compile(
1096 &self,
1097 datastores: &BTreeMap<String, DatastoreDefinition>,
1098 ) -> ModbusResult<crate::profile::UnitProfile> {
1099 let datastore = match &self.datastore {
1100 Some(datastore) => datastore.resolve(datastores)?.kind,
1101 None => DatastoreKind::default(),
1102 };
1103
1104 Ok(crate::profile::UnitProfile {
1105 unit_id: self.unit_id,
1106 name: self.name.clone(),
1107 datastore,
1108 points: self.points.clone(),
1109 response_delay_ms: self.response_delay_ms,
1110 word_order: self.word_order,
1111 broadcast_enabled: self.broadcast_enabled,
1112 tags: self.tags.clone(),
1113 })
1114 }
1115
1116 fn binding_summary_map(&self) -> BTreeMap<String, Vec<String>> {
1117 self.action_bindings
1118 .iter()
1119 .map(|binding| {
1120 (
1121 binding.point_id.clone(),
1122 binding
1123 .bindings
1124 .iter()
1125 .map(ActionBindingDefinition::summary)
1126 .collect(),
1127 )
1128 })
1129 .collect()
1130 }
1131
1132 fn binding_definition_map(&self) -> BTreeMap<String, Vec<ActionBindingDefinition>> {
1133 self.action_bindings
1134 .iter()
1135 .map(|binding| (binding.point_id.clone(), binding.bindings.clone()))
1136 .collect()
1137 }
1138}
1139
1140#[derive(Debug, Clone, Serialize, Deserialize)]
1142pub struct GeneratedPresetDefinition {
1143 pub devices: usize,
1144 pub points_per_device: usize,
1145 #[serde(default)]
1146 pub datastore: Option<DatastoreSelector>,
1147}
1148
1149impl GeneratedPresetDefinition {
1150 fn build(
1151 &self,
1152 datastores: &BTreeMap<String, DatastoreDefinition>,
1153 ) -> ModbusResult<SimulatorProfile> {
1154 let mut profile = GeneratedProfilePreset::new(self.devices, self.points_per_device).build();
1155 if let Some(datastore) = &self.datastore {
1156 let datastore = datastore.resolve(datastores)?.kind;
1157 for unit in &mut profile.units {
1158 unit.datastore = datastore.clone();
1159 }
1160 }
1161 Ok(profile)
1162 }
1163}
1164
1165#[derive(Debug, Clone, Serialize, Deserialize)]
1167pub struct SessionDefinition {
1168 pub transport: String,
1169 #[serde(default)]
1170 pub service_name: Option<String>,
1171 #[serde(default)]
1172 pub devices: Vec<String>,
1173 #[serde(default)]
1174 pub preset: Option<String>,
1175 #[serde(default)]
1176 pub trace: SessionTraceConfig,
1177 #[serde(default)]
1178 pub reset: SessionResetPolicy,
1179 #[serde(default)]
1180 pub fault_presets: BTreeMap<String, FaultInjectionConfig>,
1181 #[serde(default)]
1182 pub active_fault_preset: Option<String>,
1183 #[serde(default)]
1184 pub active_response_profile: Option<String>,
1185 #[serde(default)]
1186 pub behavior_sets: BTreeMap<String, BehaviorSetDefinition>,
1187 #[serde(default)]
1188 pub active_behavior_set: Option<String>,
1189 #[serde(default)]
1190 pub readiness_timeout_ms: Option<u64>,
1191 #[serde(default)]
1192 pub control: SessionControlConfig,
1193}
1194
1195#[derive(Debug, Clone, Serialize, Deserialize)]
1197pub struct SessionTraceConfig {
1198 #[serde(default = "default_true")]
1199 pub enabled: bool,
1200 #[serde(default)]
1201 pub capacity: Option<usize>,
1202}
1203
1204impl Default for SessionTraceConfig {
1205 fn default() -> Self {
1206 Self {
1207 enabled: true,
1208 capacity: None,
1209 }
1210 }
1211}
1212
1213impl SessionTraceConfig {
1214 fn resolved(&self, defaults: &SimulatorDefaults) -> Self {
1215 Self {
1216 enabled: self.enabled,
1217 capacity: Some(self.capacity.unwrap_or(defaults.trace_capacity)),
1218 }
1219 }
1220
1221 pub fn buffer_capacity(&self) -> usize {
1222 self.capacity.unwrap_or(default_trace_capacity())
1223 }
1224}
1225
1226fn default_true() -> bool {
1227 true
1228}
1229
1230#[derive(Debug, Clone, Serialize, Deserialize)]
1232pub struct SessionResetPolicy {
1233 #[serde(default = "default_true")]
1234 pub clear_fault_preset: bool,
1235 #[serde(default = "default_true")]
1236 pub clear_response_profile: bool,
1237 #[serde(default = "default_true")]
1238 pub clear_behavior_set: bool,
1239 #[serde(default = "default_true")]
1240 pub clear_trace_buffer: bool,
1241}
1242
1243impl Default for SessionResetPolicy {
1244 fn default() -> Self {
1245 Self {
1246 clear_fault_preset: true,
1247 clear_response_profile: true,
1248 clear_behavior_set: true,
1249 clear_trace_buffer: true,
1250 }
1251 }
1252}
1253
1254#[derive(Debug, Clone, Serialize, Deserialize)]
1256pub struct SessionControlConfig {
1257 #[serde(default = "default_trace_tail")]
1258 pub default_trace_tail: usize,
1259 #[serde(default = "default_point_limit")]
1260 pub default_point_limit: usize,
1261}
1262
1263impl Default for SessionControlConfig {
1264 fn default() -> Self {
1265 Self {
1266 default_trace_tail: default_trace_tail(),
1267 default_point_limit: default_point_limit(),
1268 }
1269 }
1270}
1271
1272fn default_trace_tail() -> usize {
1273 20
1274}
1275
1276fn default_point_limit() -> usize {
1277 100
1278}
1279
1280#[derive(Debug, Clone, Serialize, Deserialize)]
1282pub struct ModbusServiceLaunchConfig {
1283 pub transport: ModbusTransportLaunch,
1284 #[serde(default)]
1285 pub profile: Option<SimulatorProfile>,
1286 #[serde(default)]
1287 pub devices: Option<usize>,
1288 #[serde(default)]
1289 pub points_per_device: Option<usize>,
1290}
1291
1292impl ModbusServiceLaunchConfig {
1293 fn from_session(
1294 defaults: &SimulatorDefaults,
1295 transport: &TransportDefinition,
1296 profile: SimulatorProfile,
1297 ) -> Self {
1298 let transport = match transport {
1299 TransportDefinition::Tcp {
1300 bind,
1301 port,
1302 performance_preset,
1303 } => {
1304 let bind_address: SocketAddr = format!("{}:{}", bind, port)
1305 .parse()
1306 .unwrap_or_else(|_| SocketAddr::from(([0, 0, 0, 0], *port)));
1307 ModbusTransportLaunch::Tcp {
1308 bind_addr: bind_address,
1309 performance_preset: performance_preset
1310 .unwrap_or(defaults.tcp_performance_preset),
1311 }
1312 }
1313 TransportDefinition::Rtu { config } => {
1314 let mut config = config.clone();
1315 if matches!(config.performance_preset, RtuPerformancePreset::Default) {
1316 config.performance_preset = defaults.rtu_performance_preset;
1317 }
1318 ModbusTransportLaunch::Rtu { config }
1319 }
1320 };
1321
1322 Self {
1323 transport,
1324 profile: Some(profile),
1325 devices: None,
1326 points_per_device: None,
1327 }
1328 }
1329}
1330
1331#[derive(Debug, Clone, Serialize, Deserialize)]
1333#[serde(tag = "kind", rename_all = "snake_case")]
1334pub enum ModbusTransportLaunch {
1335 Tcp {
1336 bind_addr: SocketAddr,
1337 #[serde(default)]
1338 performance_preset: TcpPerformancePreset,
1339 },
1340 Rtu {
1341 #[serde(default)]
1342 config: RtuServerConfig,
1343 },
1344}
1345
1346#[derive(Debug, Clone)]
1348pub struct CompiledModbusSession {
1349 pub session_name: String,
1350 pub launch: ProtocolLaunchSpec,
1351 pub transport_kind: CompiledTransportKind,
1352 pub profile: SimulatorProfile,
1353 pub trace: SessionTraceConfig,
1354 pub reset: SessionResetPolicy,
1355 pub control: SessionControlConfig,
1356 pub fault_presets: BTreeMap<String, FaultInjectionConfig>,
1357 pub active_fault_preset: Option<String>,
1358 pub response_profiles: BTreeMap<String, ResponseProfileDefinition>,
1359 pub active_response_profile: Option<String>,
1360 pub actions: BTreeMap<String, ActionDefinition>,
1361 pub behaviors: BTreeMap<String, BehaviorDefinition>,
1362 pub behavior_sets: BTreeMap<String, BehaviorSetDefinition>,
1363 pub active_behavior_set: Option<String>,
1364 pub point_catalog: BTreeMap<String, CompiledPointMetadata>,
1365 pub datastore_policies: Vec<DatastorePolicySummary>,
1366 pub action_binding_summaries: Vec<ActionBindingSummary>,
1367 pub behavior_binding_summaries: Vec<BehaviorBindingSummary>,
1368 pub compiled_behavior_bindings: Vec<CompiledBehaviorBinding>,
1369 pub readiness_timeout_ms: Option<u64>,
1370}
1371
1372impl CompiledModbusSession {
1373 pub fn runtime_extensions(&self) -> RuntimeExtensions {
1375 let mut extensions = RuntimeExtensions::default();
1376 if let Some(config) = self.active_runtime_fault_config() {
1377 extensions.insert_protocol_config(
1378 "modbus",
1379 json!({
1380 "fault_injection": config,
1381 }),
1382 );
1383 }
1384 if let Some(layer) = BehaviorLayer::from_compiled(self) {
1385 extensions.add_device_layer(Arc::new(layer));
1386 }
1387 extensions
1388 }
1389
1390 pub fn with_active_fault_preset(&self, preset: Option<&str>) -> ModbusResult<Self> {
1392 if let Some(name) = preset {
1393 if !self.fault_presets.contains_key(name) {
1394 return Err(ModbusError::Config(format!(
1395 "unknown fault preset '{}'",
1396 name
1397 )));
1398 }
1399 }
1400
1401 let mut cloned = self.clone();
1402 cloned.active_fault_preset = preset.map(|value| value.to_string());
1403 Ok(cloned)
1404 }
1405
1406 pub fn with_active_response_profile(&self, profile: Option<&str>) -> ModbusResult<Self> {
1408 if let Some(name) = profile {
1409 if !self.response_profiles.contains_key(name) {
1410 return Err(ModbusError::Config(format!(
1411 "unknown response profile '{}'",
1412 name
1413 )));
1414 }
1415 }
1416
1417 let mut cloned = self.clone();
1418 cloned.active_response_profile = profile.map(|value| value.to_string());
1419 Ok(cloned)
1420 }
1421
1422 pub fn with_active_behavior_set(&self, behavior_set: Option<&str>) -> ModbusResult<Self> {
1424 if let Some(name) = behavior_set {
1425 if !self.behavior_sets.contains_key(name) {
1426 return Err(ModbusError::Config(format!(
1427 "unknown behavior set '{}'",
1428 name
1429 )));
1430 }
1431 }
1432
1433 let mut cloned = self.clone();
1434 cloned.active_behavior_set = behavior_set.map(|value| value.to_string());
1435 Ok(cloned)
1436 }
1437
1438 pub fn active_fault_config(&self) -> Option<FaultInjectionConfig> {
1440 self.active_fault_preset
1441 .as_ref()
1442 .and_then(|name| self.fault_presets.get(name).cloned())
1443 }
1444
1445 pub fn active_response_profile_definition(&self) -> Option<ResponseProfileDefinition> {
1447 self.active_response_profile
1448 .as_ref()
1449 .and_then(|name| self.response_profiles.get(name).cloned())
1450 }
1451
1452 pub fn active_runtime_fault_config(&self) -> Option<FaultInjectionConfig> {
1454 let mut merged = FaultInjectionConfig::new();
1455 let mut enabled = false;
1456 let mut has_source = false;
1457
1458 if let Some(config) = self.active_fault_config() {
1459 has_source = true;
1460 enabled |= config.enabled;
1461 merged.faults.extend(config.faults);
1462 }
1463 if let Some(config) = self
1464 .active_response_profile_definition()
1465 .and_then(|profile| profile.to_fault_injection_config(self.transport_kind))
1466 {
1467 has_source = true;
1468 enabled |= config.enabled;
1469 merged.faults.extend(config.faults);
1470 }
1471
1472 if !has_source {
1473 None
1474 } else {
1475 merged.enabled = enabled;
1476 Some(merged)
1477 }
1478 }
1479
1480 pub fn point_metadata(
1481 &self,
1482 device_id: &str,
1483 point_id: &str,
1484 ) -> Option<&CompiledPointMetadata> {
1485 self.point_catalog
1486 .get(&point_catalog_key(device_id, point_id))
1487 }
1488}
1489
1490#[derive(Debug, Clone, Serialize)]
1492pub struct ModbusConfigSummary {
1493 pub transports: Vec<String>,
1494 pub datastores: Vec<String>,
1495 pub devices: Vec<String>,
1496 pub sessions: Vec<SessionSummary>,
1497 pub presets: Vec<String>,
1498 pub actions: Vec<String>,
1499 pub behaviors: Vec<String>,
1500 pub response_profiles: Vec<String>,
1501}
1502
1503#[derive(Debug, Clone, Serialize)]
1505pub struct SessionSummary {
1506 pub name: String,
1507 pub transport: String,
1508 pub devices: Vec<String>,
1509 pub preset: Option<String>,
1510 pub active_fault_preset: Option<String>,
1511 pub active_response_profile: Option<String>,
1512 pub active_behavior_set: Option<String>,
1513}
1514
1515#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1517pub enum CompiledTransportKind {
1518 Tcp,
1519 Rtu,
1520}
1521
1522#[derive(Debug, Clone, Serialize)]
1524pub struct CompiledPointMetadata {
1525 pub device_id: String,
1526 pub point_id: String,
1527 pub source_datastore: Option<String>,
1528 pub read_only: bool,
1529 pub invalid: bool,
1530 pub action_bindings: Vec<String>,
1531 pub behavior_bindings: Vec<String>,
1532}
1533
1534#[derive(Debug, Clone, Serialize)]
1536pub struct DatastorePolicySummary {
1537 pub name: String,
1538 pub kind: String,
1539 pub invalid_ranges: usize,
1540 pub readonly_ranges: usize,
1541 pub typed_blocks: usize,
1542 pub has_default_value: bool,
1543 pub repeat: Option<DatastoreRepeatPolicy>,
1544 pub initialization: Option<String>,
1545}
1546
1547#[derive(Debug, Clone, Serialize)]
1549pub struct ActionBindingSummary {
1550 pub device_id: String,
1551 pub point_id: String,
1552 pub bindings: Vec<String>,
1553}
1554
1555#[derive(Debug, Clone, Serialize)]
1557pub struct BehaviorBindingSummary {
1558 pub device_id: String,
1559 pub point_id: String,
1560 pub behavior: String,
1561 pub behavior_set: String,
1562 pub trigger: BehaviorTrigger,
1563}
1564
1565#[derive(Debug, Clone)]
1567pub struct CompiledBehaviorBinding {
1568 pub name: String,
1569 pub behavior_set: String,
1570 pub device_id: String,
1571 pub point_id: String,
1572 pub trigger: BehaviorTrigger,
1573 pub condition: Option<BehaviorCondition>,
1574 pub interval_ms: Option<u64>,
1575 pub actions: Vec<String>,
1576}
1577
1578struct CompiledSessionMetadata {
1579 point_catalog: BTreeMap<String, CompiledPointMetadata>,
1580 datastore_policies: Vec<DatastorePolicySummary>,
1581 action_bindings: Vec<ActionBindingSummary>,
1582 behavior_bindings: Vec<BehaviorBindingSummary>,
1583 compiled_behavior_bindings: Vec<CompiledBehaviorBinding>,
1584}
1585
1586#[derive(Debug, Clone)]
1587struct MatchedBehaviorTarget {
1588 device_id: String,
1589 point_id: String,
1590}
1591
1592fn point_catalog_key(device_id: &str, point_id: &str) -> String {
1593 format!("{}/{}", device_id, point_id)
1594}
1595
1596fn matching_behavior_targets(
1597 behavior: &BehaviorDefinition,
1598 profile: &SimulatorProfile,
1599) -> Vec<MatchedBehaviorTarget> {
1600 let mut matches = Vec::new();
1601 for unit in &profile.units {
1602 let device_id = format!("modbus-{}", unit.unit_id);
1603 if let Some(expected_device) = &behavior.target.device_id {
1604 if &device_id != expected_device {
1605 continue;
1606 }
1607 }
1608 if let Some(expected_unit) = behavior.target.unit_id {
1609 if unit.unit_id != expected_unit {
1610 continue;
1611 }
1612 }
1613 if unit
1614 .points
1615 .iter()
1616 .any(|point| point.id == behavior.target.point_id)
1617 {
1618 matches.push(MatchedBehaviorTarget {
1619 device_id,
1620 point_id: behavior.target.point_id.clone(),
1621 });
1622 }
1623 }
1624 matches
1625}
1626
1627#[derive(Debug, Clone, Serialize)]
1629pub struct ModbusSchemaSummary {
1630 pub kind: &'static str,
1631 pub formats: Vec<&'static str>,
1632 pub top_level_sections: Vec<SchemaSection>,
1633 pub commands: Vec<&'static str>,
1634 pub notes: Vec<&'static str>,
1635}
1636
1637#[derive(Debug, Clone, Serialize)]
1639pub struct SchemaSection {
1640 pub name: &'static str,
1641 pub purpose: &'static str,
1642 pub required: bool,
1643}
1644
1645pub fn schema_summary() -> ModbusSchemaSummary {
1647 ModbusSchemaSummary {
1648 kind: "modbus_simulator_config",
1649 formats: vec!["yaml", "json", "toml"],
1650 top_level_sections: vec![
1651 SchemaSection {
1652 name: "defaults",
1653 purpose: "Shared trace and readiness defaults used during session compilation",
1654 required: false,
1655 },
1656 SchemaSection {
1657 name: "transports",
1658 purpose: "Named TCP or RTU endpoints selected by sessions",
1659 required: true,
1660 },
1661 SchemaSection {
1662 name: "datastores",
1663 purpose: "Named dense or sparse datastore definitions reused by devices and presets",
1664 required: false,
1665 },
1666 SchemaSection {
1667 name: "devices",
1668 purpose: "Named unit bundles with datastore refs, point catalogs, timing, and tags",
1669 required: false,
1670 },
1671 SchemaSection {
1672 name: "actions",
1673 purpose: "Deterministic built-in action catalog referenced by point bindings",
1674 required: false,
1675 },
1676 SchemaSection {
1677 name: "behaviors",
1678 purpose: "Deterministic behavior graph referencing actions, triggers, conditions, and point targets",
1679 required: false,
1680 },
1681 SchemaSection {
1682 name: "sessions",
1683 purpose: "Canonical run targets selecting transport, devices, control defaults, trace, reset, fault presets, response profiles, and behavior sets",
1684 required: true,
1685 },
1686 SchemaSection {
1687 name: "response_profiles",
1688 purpose: "Named wire-level response behaviors compiled into runtime fault policies",
1689 required: false,
1690 },
1691 SchemaSection {
1692 name: "presets",
1693 purpose: "Quickstart generators that compile to generated profiles",
1694 required: false,
1695 },
1696 ],
1697 commands: vec![
1698 "mabi validate modbus-config <file>",
1699 "mabi inspect modbus-config <file>",
1700 "mabi serve modbus --config <file> --session <name>",
1701 "mabi control modbus ...",
1702 ],
1703 notes: vec![
1704 "Config files are source-of-truth and stay file-backed",
1705 "Runtime mutations do not rewrite config files",
1706 "Named fault presets are scoped to a session definition",
1707 "Deterministic actions, behaviors, and response profiles compile into immutable session metadata",
1708 ],
1709 }
1710}
1711
1712#[cfg(test)]
1713mod tests {
1714 use std::collections::BTreeMap;
1715
1716 use super::{
1717 default_trace_capacity, CompiledModbusSession, CompiledTransportKind, DatastoreDefinition,
1718 DatastoreSelector, GeneratedPresetDefinition, ModbusSimulatorConfig,
1719 ResponseProfileDefinition, SessionControlConfig, SessionDefinition, SessionTraceConfig,
1720 SimulatorDefaults, TransportDefinition,
1721 };
1722 use crate::fault_injection::FaultInjectionConfig;
1723 use crate::profile::SimulatorProfile;
1724
1725 #[test]
1726 fn config_parses_yaml_and_compiles_session() {
1727 let config = ModbusSimulatorConfig::from_str_with_format(
1728 r#"
1729defaults:
1730 trace_capacity: 128
1731transports:
1732 local:
1733 kind: tcp
1734 bind: 127.0.0.1
1735 port: 1502
1736devices:
1737 plant:
1738 units:
1739 - unit_id: 1
1740 name: Pump
1741sessions:
1742 demo:
1743 transport: local
1744 devices: [plant]
1745"#,
1746 "yaml",
1747 )
1748 .unwrap();
1749
1750 let compiled = config.compile_session("demo").unwrap();
1751 assert_eq!(compiled.session_name, "demo");
1752 assert!(compiled.launch.config["profile"]["units"].is_array());
1753 assert_eq!(compiled.trace.buffer_capacity(), 128);
1754 }
1755
1756 #[test]
1757 fn config_rejects_unknown_transport_reference() {
1758 let config = ModbusSimulatorConfig {
1759 sessions: BTreeMap::from([(
1760 "broken".to_string(),
1761 SessionDefinition {
1762 transport: "missing".to_string(),
1763 service_name: None,
1764 devices: vec![],
1765 preset: Some("default".to_string()),
1766 trace: SessionTraceConfig::default(),
1767 reset: Default::default(),
1768 control: Default::default(),
1769 fault_presets: BTreeMap::new(),
1770 active_fault_preset: None,
1771 active_response_profile: None,
1772 behavior_sets: BTreeMap::new(),
1773 active_behavior_set: None,
1774 readiness_timeout_ms: None,
1775 },
1776 )]),
1777 presets: BTreeMap::from([(
1778 "default".to_string(),
1779 GeneratedPresetDefinition {
1780 devices: 1,
1781 points_per_device: 4,
1782 datastore: None,
1783 },
1784 )]),
1785 ..Default::default()
1786 };
1787
1788 let error = config.validate().unwrap_err().to_string();
1789 assert!(error.contains("unknown transport"));
1790 }
1791
1792 #[test]
1793 fn compiled_session_runtime_extensions_include_active_fault_preset() {
1794 let compiled = CompiledModbusSession {
1795 session_name: "demo".into(),
1796 launch: mabi_runtime::ProtocolLaunchSpec {
1797 protocol: "modbus".into(),
1798 name: Some("demo".into()),
1799 config: serde_json::json!({}),
1800 },
1801 transport_kind: CompiledTransportKind::Tcp,
1802 profile: SimulatorProfile::new(),
1803 trace: SessionTraceConfig {
1804 enabled: true,
1805 capacity: Some(default_trace_capacity()),
1806 },
1807 reset: Default::default(),
1808 control: SessionControlConfig::default(),
1809 fault_presets: BTreeMap::from([("delay".into(), FaultInjectionConfig::default())]),
1810 active_fault_preset: Some("delay".into()),
1811 response_profiles: BTreeMap::new(),
1812 active_response_profile: None,
1813 actions: BTreeMap::new(),
1814 behaviors: BTreeMap::new(),
1815 behavior_sets: BTreeMap::new(),
1816 active_behavior_set: None,
1817 point_catalog: BTreeMap::new(),
1818 datastore_policies: Vec::new(),
1819 action_binding_summaries: Vec::new(),
1820 behavior_binding_summaries: Vec::new(),
1821 compiled_behavior_bindings: Vec::new(),
1822 readiness_timeout_ms: None,
1823 };
1824
1825 let extensions = compiled.runtime_extensions();
1826 assert!(extensions.protocol_config("modbus").is_some());
1827 }
1828
1829 #[test]
1830 fn generated_preset_can_override_datastore_kind() {
1831 let preset = GeneratedPresetDefinition {
1832 devices: 1,
1833 points_per_device: 4,
1834 datastore: Some(DatastoreSelector::Inline(DatastoreDefinition::from(
1835 crate::profile::DatastoreKind::Sparse {
1836 config: Default::default(),
1837 },
1838 ))),
1839 };
1840
1841 let profile = preset.build(&BTreeMap::new()).unwrap();
1842 assert!(matches!(
1843 profile.units[0].datastore,
1844 crate::profile::DatastoreKind::Sparse { .. }
1845 ));
1846 }
1847
1848 #[test]
1849 fn compiled_session_runtime_extensions_include_active_response_profile_faults() {
1850 let compiled = CompiledModbusSession {
1851 session_name: "demo".into(),
1852 launch: mabi_runtime::ProtocolLaunchSpec {
1853 protocol: "modbus".into(),
1854 name: Some("demo".into()),
1855 config: serde_json::json!({}),
1856 },
1857 transport_kind: CompiledTransportKind::Tcp,
1858 profile: SimulatorProfile::new(),
1859 trace: SessionTraceConfig::default(),
1860 reset: Default::default(),
1861 control: SessionControlConfig::default(),
1862 fault_presets: BTreeMap::new(),
1863 active_fault_preset: None,
1864 response_profiles: BTreeMap::from([(
1865 "slow".into(),
1866 ResponseProfileDefinition {
1867 delay_ms: Some(25),
1868 ..Default::default()
1869 },
1870 )]),
1871 active_response_profile: Some("slow".into()),
1872 actions: BTreeMap::new(),
1873 behaviors: BTreeMap::new(),
1874 behavior_sets: BTreeMap::new(),
1875 active_behavior_set: None,
1876 point_catalog: BTreeMap::new(),
1877 datastore_policies: Vec::new(),
1878 action_binding_summaries: Vec::new(),
1879 behavior_binding_summaries: Vec::new(),
1880 compiled_behavior_bindings: Vec::new(),
1881 readiness_timeout_ms: None,
1882 };
1883
1884 let extensions = compiled.runtime_extensions();
1885 let config = extensions.protocol_config("modbus").unwrap();
1886 assert_eq!(
1887 config["fault_injection"]["faults"][0]["type"],
1888 "delayed_response"
1889 );
1890 }
1891
1892 #[test]
1893 fn named_datastore_references_compile_into_profiles() {
1894 let config = ModbusSimulatorConfig::from_str_with_format(
1895 r#"
1896transports:
1897 local:
1898 kind: tcp
1899 bind: 127.0.0.1
1900 port: 1502
1901datastores:
1902 sparse_lab:
1903 kind: sparse
1904devices:
1905 lab:
1906 units:
1907 - unit_id: 7
1908 name: TestBench
1909 datastore: sparse_lab
1910sessions:
1911 demo:
1912 transport: local
1913 devices: [lab]
1914"#,
1915 "yaml",
1916 )
1917 .unwrap();
1918
1919 let compiled = config.compile_session("demo").unwrap();
1920 assert!(matches!(
1921 compiled.profile.units[0].datastore,
1922 crate::profile::DatastoreKind::Sparse { .. }
1923 ));
1924 }
1925
1926 #[test]
1927 fn config_compiles_action_bindings_and_point_catalog_metadata() {
1928 let config = ModbusSimulatorConfig::from_str_with_format(
1929 r#"
1930transports:
1931 local:
1932 kind: tcp
1933 bind: 127.0.0.1
1934 port: 1502
1935datastores:
1936 sparse_named:
1937 kind: sparse
1938 readonly_ranges:
1939 - register_type: holding_register
1940 start: 5
1941 quantity: 1
1942 invalid_ranges:
1943 - register_type: holding_register
1944 start: 8
1945 quantity: 1
1946actions:
1947 clamp_temp:
1948 kind: clamp
1949 min: 0
1950 max: 100
1951response_profiles:
1952 slow:
1953 delay_ms: 10
1954devices:
1955 plant:
1956 units:
1957 - unit_id: 1
1958 name: Pump
1959 datastore: sparse_named
1960 points:
1961 - id: temperature
1962 name: Temperature
1963 register_type: holding_register
1964 address: 5
1965 data_type: uint16
1966 action_bindings:
1967 - point_id: temperature
1968 bindings:
1969 - action: clamp_temp
1970 trigger: on_write
1971sessions:
1972 demo:
1973 transport: local
1974 devices: [plant]
1975 active_response_profile: slow
1976"#,
1977 "yaml",
1978 )
1979 .unwrap();
1980
1981 let compiled = config.compile_session("demo").unwrap();
1982 let metadata = compiled
1983 .point_metadata("modbus-1", "temperature")
1984 .expect("compiled point metadata");
1985 assert_eq!(metadata.source_datastore.as_deref(), Some("sparse_named"));
1986 assert!(metadata.read_only);
1987 assert_eq!(metadata.action_bindings, vec!["clamp_temp@on_write"]);
1988 assert_eq!(compiled.active_response_profile.as_deref(), Some("slow"));
1989 }
1990
1991 #[test]
1992 fn schema_defaults_are_stable() {
1993 let defaults = SimulatorDefaults::default();
1994 assert_eq!(defaults.trace_capacity, default_trace_capacity());
1995 assert!(matches!(
1996 TransportDefinition::Tcp {
1997 bind: "0.0.0.0".into(),
1998 port: 502,
1999 performance_preset: None,
2000 },
2001 TransportDefinition::Tcp { .. }
2002 ));
2003 }
2004}