1use crate::address::SlmpAddress;
2use crate::client::SlmpClient;
3use crate::device_ranges::{
4 SlmpDeviceRangeCatalog, SlmpDeviceRangeCategory, SlmpDeviceRangeEntry, SlmpDeviceRangeFamily,
5 SlmpDeviceRangeNotation,
6};
7use crate::error::SlmpError;
8use crate::helpers::{SlmpValue, read_typed, write_typed};
9use crate::model::{SlmpBlockRead, SlmpBlockWrite, SlmpBlockWriteOptions, SlmpDeviceAddress};
10use std::future::Future;
11
12const DEFAULT_RANGE_END_CODE: u16 = 0x4031;
13const IQF_RANGE_END_CODE: u16 = 0xC056;
14
15#[derive(Debug, Clone, Copy)]
16struct RouteCapabilities {
17 block: bool,
18 random: bool,
19 lz: bool,
20}
21
22#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
23#[serde(rename_all = "camelCase")]
24pub struct SlmpRouteValidationOptions {
25 #[serde(default = "default_word_device")]
26 pub word_device: String,
27 #[serde(default = "default_dword_device")]
28 pub dword_device: String,
29 #[serde(default = "default_float_device")]
30 pub float_device: String,
31 #[serde(default = "default_bit_device")]
32 pub bit_device: String,
33 #[serde(default = "default_lz_device")]
34 pub lz_device: String,
35 #[serde(default)]
36 pub range_family: Option<SlmpDeviceRangeFamily>,
37 #[serde(default = "default_range_error_devices")]
38 pub range_error_devices: Vec<String>,
39}
40
41impl Default for SlmpRouteValidationOptions {
42 fn default() -> Self {
43 Self {
44 word_device: default_word_device(),
45 dword_device: default_dword_device(),
46 float_device: default_float_device(),
47 bit_device: default_bit_device(),
48 lz_device: default_lz_device(),
49 range_family: None,
50 range_error_devices: default_range_error_devices(),
51 }
52 }
53}
54
55impl SlmpRouteValidationOptions {
56 pub fn normalized(mut self) -> Self {
57 if self.word_device.trim().is_empty() {
58 self.word_device = default_word_device();
59 }
60 if self.dword_device.trim().is_empty() {
61 self.dword_device = default_dword_device();
62 }
63 if self.float_device.trim().is_empty() {
64 self.float_device = default_float_device();
65 }
66 if self.bit_device.trim().is_empty() {
67 self.bit_device = default_bit_device();
68 }
69 if self.lz_device.trim().is_empty() {
70 self.lz_device = default_lz_device();
71 }
72 if self.range_error_devices.is_empty() {
73 self.range_error_devices = default_range_error_devices();
74 }
75 self.range_error_devices = self
76 .range_error_devices
77 .into_iter()
78 .map(|device| device.trim().to_ascii_uppercase())
79 .filter(|device| !device.is_empty())
80 .collect();
81 self
82 }
83}
84
85#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
86#[serde(rename_all = "camelCase")]
87pub struct SlmpRouteValidationSummary {
88 pub passed: usize,
89 pub failed: usize,
90 pub warned: usize,
91 pub skipped: usize,
92}
93
94impl SlmpRouteValidationSummary {
95 pub fn is_success(&self) -> bool {
96 self.failed == 0
97 }
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
101#[serde(rename_all = "PascalCase")]
102pub enum SlmpRouteValidationStatus {
103 Passed,
104 Failed,
105 Warning,
106 Skipped,
107}
108
109#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
110#[serde(rename_all = "camelCase")]
111pub struct SlmpRouteValidationCase {
112 pub route: String,
113 pub name: String,
114 pub status: SlmpRouteValidationStatus,
115 pub detail: String,
116}
117
118#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
119#[serde(rename_all = "camelCase")]
120pub struct SlmpRouteValidationReport {
121 pub model: String,
122 pub family: SlmpDeviceRangeFamily,
123 pub options: SlmpRouteValidationOptions,
124 pub summary: SlmpRouteValidationSummary,
125 pub cases: Vec<SlmpRouteValidationCase>,
126}
127
128impl SlmpRouteValidationReport {
129 pub fn is_success(&self) -> bool {
130 self.summary.is_success()
131 }
132
133 fn push(
134 &mut self,
135 route: &str,
136 name: &str,
137 status: SlmpRouteValidationStatus,
138 detail: impl Into<String>,
139 ) {
140 match status {
141 SlmpRouteValidationStatus::Passed => self.summary.passed += 1,
142 SlmpRouteValidationStatus::Failed => self.summary.failed += 1,
143 SlmpRouteValidationStatus::Warning => self.summary.warned += 1,
144 SlmpRouteValidationStatus::Skipped => self.summary.skipped += 1,
145 }
146 self.cases.push(SlmpRouteValidationCase {
147 route: route.to_string(),
148 name: name.to_string(),
149 status,
150 detail: detail.into(),
151 });
152 }
153}
154
155pub async fn run_route_validation_compare(
156 client: &SlmpClient,
157 options: SlmpRouteValidationOptions,
158) -> Result<SlmpRouteValidationReport, SlmpError> {
159 let options = options.normalized();
160 let model = client
161 .read_type_name()
162 .await
163 .map(|info| info.model)
164 .unwrap_or_else(|_| "unknown".to_string());
165 let family = match options.range_family {
166 Some(family) => family,
167 None => client.configured_device_range_family().await,
168 };
169 let options = apply_family_default_devices(options, family);
170 let capabilities = route_capabilities(family);
171 let mut report = SlmpRouteValidationReport {
172 model,
173 family,
174 options: options.clone(),
175 summary: SlmpRouteValidationSummary::default(),
176 cases: Vec::new(),
177 };
178
179 if capabilities.block {
180 record_case(&mut report, "block", "read_block_matches_direct", || {
181 validate_block_read(client, &options)
182 })
183 .await;
184 record_case(&mut report, "block", "write_block_roundtrip", || {
185 validate_block_write(client, &options)
186 })
187 .await;
188 } else {
189 report.push(
190 "block",
191 "read_block_matches_direct",
192 SlmpRouteValidationStatus::Skipped,
193 format!("{family:?} does not support block route 0x0406"),
194 );
195 report.push(
196 "block",
197 "write_block_roundtrip",
198 SlmpRouteValidationStatus::Skipped,
199 format!("{family:?} does not support block route 0x1406"),
200 );
201 }
202 if capabilities.lz {
203 record_case(&mut report, "block", "lz_blocks_rejected", || {
204 validate_lz_block_guards(client, &options)
205 })
206 .await;
207 } else {
208 report.push(
209 "block",
210 "lz_blocks_rejected",
211 SlmpRouteValidationStatus::Skipped,
212 format!("{family:?} does not support LZ"),
213 );
214 }
215 if capabilities.random {
216 record_case(&mut report, "random", "read_random_matches_direct", || {
217 validate_random_read(client, &options)
218 })
219 .await;
220 record_case(&mut report, "random", "write_random_roundtrip", || {
221 validate_random_write(client, &options)
222 })
223 .await;
224 } else {
225 report.push(
226 "random",
227 "read_random_matches_direct",
228 SlmpRouteValidationStatus::Skipped,
229 format!("{family:?} does not support random route 0x0403"),
230 );
231 report.push(
232 "random",
233 "write_random_roundtrip",
234 SlmpRouteValidationStatus::Skipped,
235 format!("{family:?} does not support random route 0x1402"),
236 );
237 }
238 if capabilities.lz {
239 record_case(&mut report, "random", "lz_word_entries_rejected", || {
240 validate_lz_random_word_guards(client, &options)
241 })
242 .await;
243 } else {
244 report.push(
245 "random",
246 "lz_word_entries_rejected",
247 SlmpRouteValidationStatus::Skipped,
248 format!("{family:?} does not support LZ"),
249 );
250 }
251 record_case(&mut report, "typed", "word_dword_float_roundtrip", || {
252 validate_typed_roundtrip(client, &options)
253 })
254 .await;
255 if capabilities.lz && capabilities.random {
256 record_case(&mut report, "typed", "lz_random_dword_roundtrip", || {
257 validate_lz_typed_roundtrip(client, &options)
258 })
259 .await;
260 record_case(&mut report, "typed", "lz_invalid_dtypes_rejected", || {
261 validate_lz_typed_guards(client, &options)
262 })
263 .await;
264 } else {
265 report.push(
266 "typed",
267 "lz_random_dword_roundtrip",
268 SlmpRouteValidationStatus::Skipped,
269 format!("{family:?} does not support LZ random dword route"),
270 );
271 report.push(
272 "typed",
273 "lz_invalid_dtypes_rejected",
274 SlmpRouteValidationStatus::Skipped,
275 format!("{family:?} does not support LZ random dword route"),
276 );
277 }
278
279 validate_range_error_routes(client, &options, family, capabilities, &mut report).await?;
280 Ok(report)
281}
282
283async fn record_case<F, Fut>(
284 report: &mut SlmpRouteValidationReport,
285 route: &str,
286 name: &str,
287 run: F,
288) where
289 F: FnOnce() -> Fut,
290 Fut: Future<Output = Result<String, SlmpError>>,
291{
292 match run().await {
293 Ok(detail) => report.push(route, name, SlmpRouteValidationStatus::Passed, detail),
294 Err(error) => report.push(
295 route,
296 name,
297 SlmpRouteValidationStatus::Failed,
298 error.to_string(),
299 ),
300 }
301}
302
303async fn parse_for_client(
304 client: &SlmpClient,
305 address: &str,
306) -> Result<SlmpDeviceAddress, SlmpError> {
307 SlmpAddress::parse_for_plc_family(address, client.plc_family().await)
308}
309
310async fn validate_block_read(
311 client: &SlmpClient,
312 options: &SlmpRouteValidationOptions,
313) -> Result<String, SlmpError> {
314 let word = parse_for_client(client, &options.word_device).await?;
315 let bit = parse_for_client(client, &options.bit_device).await?;
316 let direct_words = client.read_words_raw(word, 2).await?;
317 let direct_bits = client.read_bits(bit, 16).await?;
318 let block = client
319 .read_block(
320 &[SlmpBlockRead {
321 device: word,
322 points: 2,
323 }],
324 &[SlmpBlockRead {
325 device: bit,
326 points: 1,
327 }],
328 )
329 .await?;
330
331 if block.word_values != direct_words {
332 return Err(SlmpError::new(format!(
333 "word mismatch: direct={direct_words:?} block={:?}",
334 block.word_values
335 )));
336 }
337 let packed_bits = pack_bit_words(&direct_bits);
338 if block.bit_values != packed_bits {
339 return Err(SlmpError::new(format!(
340 "bit mismatch: direct_packed={packed_bits:?} block={:?}",
341 block.bit_values
342 )));
343 }
344 Ok(format!(
345 "word={} points=2 bit={} bit_words=1",
346 options.word_device, options.bit_device
347 ))
348}
349
350async fn validate_block_write(
351 client: &SlmpClient,
352 options: &SlmpRouteValidationOptions,
353) -> Result<String, SlmpError> {
354 let word = parse_for_client(client, &options.word_device).await?;
355 let bit = parse_for_client(client, &options.bit_device).await?;
356 let original_words = client.read_words_raw(word, 2).await?;
357 let original_bits = client.read_bits(bit, 16).await?;
358 let write_words = vec![
359 alternate_u16(original_words[0], 0x1234),
360 alternate_u16(original_words[1], 0x5678),
361 ];
362 let write_bit_words = vec![alternate_u16(pack_bit_words(&original_bits)[0], 0x00AA)];
363
364 let test_result: Result<(), SlmpError> = async {
365 client
366 .write_block(
367 &[SlmpBlockWrite {
368 device: word,
369 values: write_words.clone(),
370 }],
371 &[SlmpBlockWrite {
372 device: bit,
373 values: write_bit_words.clone(),
374 }],
375 Some(SlmpBlockWriteOptions {
376 split_mixed_blocks: false,
377 retry_mixed_on_error: true,
378 }),
379 )
380 .await?;
381 let observed_words = client.read_words_raw(word, 2).await?;
382 if observed_words != write_words {
383 return Err(SlmpError::new(format!(
384 "word write mismatch: expected={write_words:?} observed={observed_words:?}"
385 )));
386 }
387 let observed_bits = pack_bit_words(&client.read_bits(bit, 16).await?);
388 if observed_bits != write_bit_words {
389 return Err(SlmpError::new(format!(
390 "bit write mismatch: expected={write_bit_words:?} observed={observed_bits:?}"
391 )));
392 }
393 Ok(())
394 }
395 .await;
396
397 let restore_words = client.write_words(word, &original_words).await;
398 let restore_bits = client.write_bits(bit, &original_bits).await;
399 finish_with_restore(
400 test_result,
401 &[
402 ("restore word block", restore_words),
403 ("restore bit block", restore_bits),
404 ],
405 )?;
406
407 Ok(format!(
408 "word={} values=2 bit={} bit_words=1",
409 options.word_device, options.bit_device
410 ))
411}
412
413async fn validate_lz_block_guards(
414 client: &SlmpClient,
415 options: &SlmpRouteValidationOptions,
416) -> Result<String, SlmpError> {
417 let lz = parse_for_client(client, &options.lz_device).await?;
418 expect_client_error(
419 client
420 .read_block(
421 &[SlmpBlockRead {
422 device: lz,
423 points: 1,
424 }],
425 &[],
426 )
427 .await,
428 "read_block LZ",
429 )?;
430 expect_client_error(
431 client
432 .write_block(
433 &[SlmpBlockWrite {
434 device: lz,
435 values: vec![1],
436 }],
437 &[],
438 None,
439 )
440 .await,
441 "write_block LZ",
442 )?;
443 Ok(format!(
444 "{} rejected before unsupported block route",
445 options.lz_device
446 ))
447}
448
449async fn validate_random_read(
450 client: &SlmpClient,
451 options: &SlmpRouteValidationOptions,
452) -> Result<String, SlmpError> {
453 let word = parse_for_client(client, &options.word_device).await?;
454 let dword = parse_for_client(client, &options.dword_device).await?;
455 let lz = parse_for_client(client, &options.lz_device).await?;
456 let random = client.read_random(&[word], &[dword, lz]).await?;
457 let direct_word = client.read_words_raw(word, 1).await?[0];
458 let direct_dword = client.read_dwords_raw(dword, 1).await?[0];
459 let typed_lz = expect_u32(read_typed(client, lz, "D").await?)?;
460
461 if random.word_values != vec![direct_word] {
462 return Err(SlmpError::new(format!(
463 "random word mismatch: direct={direct_word} random={:?}",
464 random.word_values
465 )));
466 }
467 if random.dword_values != vec![direct_dword, typed_lz] {
468 return Err(SlmpError::new(format!(
469 "random dword mismatch: direct=[{direct_dword}, {typed_lz}] random={:?}",
470 random.dword_values
471 )));
472 }
473 Ok(format!(
474 "word={} dword={} lz={}",
475 options.word_device, options.dword_device, options.lz_device
476 ))
477}
478
479async fn validate_random_write(
480 client: &SlmpClient,
481 options: &SlmpRouteValidationOptions,
482) -> Result<String, SlmpError> {
483 let word = parse_for_client(client, &options.word_device).await?;
484 let dword = parse_for_client(client, &options.dword_device).await?;
485 let lz = parse_for_client(client, &options.lz_device).await?;
486 let bit = parse_for_client(client, &options.bit_device).await?;
487
488 let original_word = client.read_words_raw(word, 1).await?[0];
489 let original_dword = client.read_dwords_raw(dword, 1).await?[0];
490 let original_lz = expect_u32(read_typed(client, lz, "D").await?)?;
491 let original_bit = client.read_bits(bit, 1).await?[0];
492 let write_word = alternate_u16(original_word, 0x2468);
493 let write_dword = alternate_u32(original_dword, 0x1357_2468);
494 let write_lz = alternate_u32(original_lz, 0x2468_1357);
495 let write_bit = !original_bit;
496
497 let test_result: Result<(), SlmpError> = async {
498 client
499 .write_random_words(&[(word, write_word)], &[(dword, write_dword), (lz, write_lz)])
500 .await?;
501 client.write_random_bits(&[(bit, write_bit)]).await?;
502 let observed_word = client.read_words_raw(word, 1).await?[0];
503 let observed_dword = client.read_dwords_raw(dword, 1).await?[0];
504 let observed_lz = expect_u32(read_typed(client, lz, "D").await?)?;
505 let observed_bit = client.read_bits(bit, 1).await?[0];
506 if observed_word != write_word
507 || observed_dword != write_dword
508 || observed_lz != write_lz
509 || observed_bit != write_bit
510 {
511 return Err(SlmpError::new(format!(
512 "random write mismatch: word {observed_word}/{write_word}, dword {observed_dword}/{write_dword}, lz {observed_lz}/{write_lz}, bit {observed_bit}/{write_bit}"
513 )));
514 }
515 Ok(())
516 }
517 .await;
518
519 let restore_word = client.write_words(word, &[original_word]).await;
520 let restore_dword = client.write_dwords(dword, &[original_dword]).await;
521 let restore_lz = client.write_random_words(&[], &[(lz, original_lz)]).await;
522 let restore_bit = client.write_bits(bit, &[original_bit]).await;
523 finish_with_restore(
524 test_result,
525 &[
526 ("restore random word", restore_word),
527 ("restore random dword", restore_dword),
528 ("restore random lz", restore_lz),
529 ("restore random bit", restore_bit),
530 ],
531 )?;
532
533 Ok(format!(
534 "word={} dword={} lz={} bit={}",
535 options.word_device, options.dword_device, options.lz_device, options.bit_device
536 ))
537}
538
539async fn validate_lz_random_word_guards(
540 client: &SlmpClient,
541 options: &SlmpRouteValidationOptions,
542) -> Result<String, SlmpError> {
543 let lz = parse_for_client(client, &options.lz_device).await?;
544 expect_client_error(
545 client.read_random(&[lz], &[]).await,
546 "read_random LZ word entry",
547 )?;
548 expect_client_error(
549 client.write_random_words(&[(lz, 1)], &[]).await,
550 "write_random_words LZ word entry",
551 )?;
552 Ok(format!(
553 "{} rejected as word entry; dword route is required",
554 options.lz_device
555 ))
556}
557
558async fn validate_typed_roundtrip(
559 client: &SlmpClient,
560 options: &SlmpRouteValidationOptions,
561) -> Result<String, SlmpError> {
562 let word = parse_for_client(client, &options.word_device).await?;
563 let dword = parse_for_client(client, &options.dword_device).await?;
564 let float = parse_for_client(client, &options.float_device).await?;
565 let original_word = client.read_words_raw(word, 1).await?[0];
566 let original_dword = client.read_dwords_raw(dword, 1).await?[0];
567 let original_float = client.read_dwords_raw(float, 1).await?[0];
568 let write_word = alternate_u16(original_word, 0x1357);
569 let write_dword = alternate_u32(original_dword, 0x1234_ABCD);
570 let write_float = if original_float == 12.5f32.to_bits() {
571 -7.25f32
572 } else {
573 12.5f32
574 };
575
576 let test_result: Result<(), SlmpError> = async {
577 write_typed(client, word, "U", &SlmpValue::U16(write_word)).await?;
578 let observed_word = expect_u16(read_typed(client, word, "U").await?)?;
579 if observed_word != write_word {
580 return Err(SlmpError::new(format!(
581 "typed word mismatch: expected={write_word} observed={observed_word}"
582 )));
583 }
584
585 write_typed(client, dword, "D", &SlmpValue::U32(write_dword)).await?;
586 let observed_dword = expect_u32(read_typed(client, dword, "D").await?)?;
587 if observed_dword != write_dword {
588 return Err(SlmpError::new(format!(
589 "typed dword mismatch: expected={write_dword} observed={observed_dword}"
590 )));
591 }
592
593 write_typed(client, float, "F", &SlmpValue::F32(write_float)).await?;
594 let observed_float = expect_f32(read_typed(client, float, "F").await?)?;
595 if observed_float.to_bits() != write_float.to_bits() {
596 return Err(SlmpError::new(format!(
597 "typed float mismatch: expected_bits=0x{:08X} observed_bits=0x{:08X}",
598 write_float.to_bits(),
599 observed_float.to_bits()
600 )));
601 }
602 Ok(())
603 }
604 .await;
605
606 let restore_word = client.write_words(word, &[original_word]).await;
607 let restore_dword = client.write_dwords(dword, &[original_dword]).await;
608 let restore_float = client.write_dwords(float, &[original_float]).await;
609 finish_with_restore(
610 test_result,
611 &[
612 ("restore typed word", restore_word),
613 ("restore typed dword", restore_dword),
614 ("restore typed float", restore_float),
615 ],
616 )?;
617
618 Ok(format!(
619 "word={} dword={} float={}",
620 options.word_device, options.dword_device, options.float_device
621 ))
622}
623
624async fn validate_lz_typed_roundtrip(
625 client: &SlmpClient,
626 options: &SlmpRouteValidationOptions,
627) -> Result<String, SlmpError> {
628 let lz = parse_for_client(client, &options.lz_device).await?;
629 let original = expect_u32(read_typed(client, lz, "D").await?)?;
630 let write = alternate_u32(original, 0x0BAD_F00D);
631 let test_result: Result<(), SlmpError> = async {
632 write_typed(client, lz, "D", &SlmpValue::U32(write)).await?;
633 let observed = expect_u32(read_typed(client, lz, "D").await?)?;
634 if observed != write {
635 return Err(SlmpError::new(format!(
636 "typed LZ mismatch: expected={write} observed={observed}"
637 )));
638 }
639 Ok(())
640 }
641 .await;
642 let restore = write_typed(client, lz, "D", &SlmpValue::U32(original)).await;
643 finish_with_restore(test_result, &[("restore typed LZ", restore)])?;
644 Ok(format!(
645 "{} uses random dword typed route",
646 options.lz_device
647 ))
648}
649
650async fn validate_lz_typed_guards(
651 client: &SlmpClient,
652 options: &SlmpRouteValidationOptions,
653) -> Result<String, SlmpError> {
654 let lz = parse_for_client(client, &options.lz_device).await?;
655 expect_client_error(read_typed(client, lz, "U").await, "read_typed LZ:U")?;
656 for dtype in ["U", "S", "F", "BIT"] {
657 expect_client_error(
658 write_typed(client, lz, dtype, &SlmpValue::U16(1)).await,
659 &format!("write_typed LZ:{dtype}"),
660 )?;
661 }
662 Ok(format!(
663 "{} rejects U/S/F/BIT and accepts D/L only",
664 options.lz_device
665 ))
666}
667
668async fn validate_range_error_routes(
669 client: &SlmpClient,
670 options: &SlmpRouteValidationOptions,
671 family: SlmpDeviceRangeFamily,
672 capabilities: RouteCapabilities,
673 report: &mut SlmpRouteValidationReport,
674) -> Result<(), SlmpError> {
675 let expected_end_code = expected_range_end_code(family);
676 let catalog = match options.range_family {
677 Some(family) => client.read_device_range_catalog_for_family(family).await?,
678 None => client.read_device_range_catalog().await?,
679 };
680 for device in &options.range_error_devices {
681 let Some(entry) = catalog.entries.iter().find(|entry| &entry.device == device) else {
682 report.push(
683 "range",
684 &format!("{device}_out_of_range"),
685 SlmpRouteValidationStatus::Skipped,
686 format!("{device} is not in the {family:?} range catalog"),
687 );
688 continue;
689 };
690 if !entry.supported {
691 report.push(
692 "range",
693 &format!("{device}_out_of_range"),
694 SlmpRouteValidationStatus::Skipped,
695 format!("{device} is unsupported in the {family:?} range catalog"),
696 );
697 continue;
698 }
699 record_range_case(
700 client,
701 &catalog,
702 entry,
703 expected_end_code,
704 capabilities,
705 report,
706 )
707 .await;
708 }
709 Ok(())
710}
711
712async fn record_range_case(
713 client: &SlmpClient,
714 catalog: &SlmpDeviceRangeCatalog,
715 entry: &SlmpDeviceRangeEntry,
716 expected_end_code: u16,
717 capabilities: RouteCapabilities,
718 report: &mut SlmpRouteValidationReport,
719) {
720 let name = format!("{}_out_of_range", entry.device);
721 match validate_one_range_error_device(client, catalog, entry, expected_end_code, capabilities)
722 .await
723 {
724 Ok(detail) => report.push("range", &name, SlmpRouteValidationStatus::Passed, detail),
725 Err(error) if error.message.starts_with("skip:") => report.push(
726 "range",
727 &name,
728 SlmpRouteValidationStatus::Skipped,
729 error.message.trim_start_matches("skip:").trim().to_string(),
730 ),
731 Err(error) if error.message.starts_with("warning:") => report.push(
732 "range",
733 &name,
734 SlmpRouteValidationStatus::Warning,
735 error
736 .message
737 .trim_start_matches("warning:")
738 .trim()
739 .to_string(),
740 ),
741 Err(error) => report.push(
742 "range",
743 &name,
744 SlmpRouteValidationStatus::Failed,
745 error.to_string(),
746 ),
747 }
748}
749
750async fn validate_one_range_error_device(
751 client: &SlmpClient,
752 _catalog: &SlmpDeviceRangeCatalog,
753 entry: &SlmpDeviceRangeEntry,
754 expected_end_code: u16,
755 capabilities: RouteCapabilities,
756) -> Result<String, SlmpError> {
757 let Some(upper_bound) = entry.upper_bound else {
758 return Err(SlmpError::new(format!(
759 "skip: {} has no finite upper bound",
760 entry.device
761 )));
762 };
763 let Some(out_number) = upper_bound.checked_add(1) else {
764 return Err(SlmpError::new(format!(
765 "skip: {} upper bound cannot be incremented",
766 entry.device
767 )));
768 };
769 let address = format_entry_address(entry, out_number);
770 let device = parse_for_client(client, &address).await?;
771 let mut warnings = Vec::new();
772
773 if entry.device == "LZ" {
774 if !capabilities.lz || !capabilities.random {
775 return Err(SlmpError::new(format!(
776 "skip: {} LZ random dword route is unsupported for this family",
777 entry.device
778 )));
779 }
780 expect_range_read_or_warn(
781 read_typed(client, device, "D").await,
782 "read_typed range LZ:D",
783 expected_end_code,
784 &mut warnings,
785 )?;
786 expect_range_error(
787 write_typed(client, device, "D", &SlmpValue::U32(1)).await,
788 "write_typed range LZ:D",
789 expected_end_code,
790 )?;
791 expect_range_read_or_warn(
792 client.read_random(&[], &[device]).await,
793 "read_random range LZ dword",
794 expected_end_code,
795 &mut warnings,
796 )?;
797 expect_range_error(
798 client.write_random_words(&[], &[(device, 1)]).await,
799 "write_random range LZ dword",
800 expected_end_code,
801 )?;
802 return range_detail_or_warning(
803 format!("{address} returned 0x{expected_end_code:04X} on typed/random dword routes"),
804 warnings,
805 );
806 }
807
808 if entry.is_bit_device {
809 let mut checked_routes = vec!["bit"];
810 expect_range_error(
811 client.read_bits(device, 1).await,
812 "read_bits range",
813 expected_end_code,
814 )?;
815 expect_range_error(
816 client.write_bits(device, &[false]).await,
817 "write_bits range",
818 expected_end_code,
819 )?;
820 if capabilities.random {
821 checked_routes.push("random-bit");
822 expect_range_error(
823 client.write_random_bits(&[(device, false)]).await,
824 "write_random_bits range",
825 expected_end_code,
826 )?;
827 }
828 if capabilities.block {
829 checked_routes.push("block");
830 expect_range_read_or_warn(
831 client
832 .read_block(&[], &[SlmpBlockRead { device, points: 1 }])
833 .await,
834 "read_block bit range",
835 expected_end_code,
836 &mut warnings,
837 )?;
838 expect_range_error(
839 client
840 .write_block(
841 &[],
842 &[SlmpBlockWrite {
843 device,
844 values: vec![0],
845 }],
846 None,
847 )
848 .await,
849 "write_block bit range",
850 expected_end_code,
851 )?;
852 }
853 return range_detail_or_warning(
854 format!(
855 "{address} returned 0x{expected_end_code:04X} on {} routes",
856 checked_routes.join("/")
857 ),
858 warnings,
859 );
860 }
861
862 let mut checked_routes = vec!["word", "typed"];
863 expect_range_error(
864 client.read_words_raw(device, 1).await,
865 "read_words range",
866 expected_end_code,
867 )?;
868 expect_range_error(
869 client.write_words(device, &[0]).await,
870 "write_words range",
871 expected_end_code,
872 )?;
873 expect_range_error(
874 read_typed(client, device, "U").await,
875 "read_typed range U",
876 expected_end_code,
877 )?;
878 expect_range_error(
879 write_typed(client, device, "U", &SlmpValue::U16(0)).await,
880 "write_typed range U",
881 expected_end_code,
882 )?;
883 if capabilities.random {
884 checked_routes.push("random");
885 expect_range_read_or_warn(
886 client.read_random(&[device], &[]).await,
887 "read_random word range",
888 expected_end_code,
889 &mut warnings,
890 )?;
891 expect_range_error(
892 client.write_random_words(&[(device, 0)], &[]).await,
893 "write_random_words range",
894 expected_end_code,
895 )?;
896 }
897 if capabilities.block {
898 checked_routes.push("block");
899 expect_range_read_or_warn(
900 client
901 .read_block(&[SlmpBlockRead { device, points: 1 }], &[])
902 .await,
903 "read_block word range",
904 expected_end_code,
905 &mut warnings,
906 )?;
907 expect_range_error(
908 client
909 .write_block(
910 &[SlmpBlockWrite {
911 device,
912 values: vec![0],
913 }],
914 &[],
915 None,
916 )
917 .await,
918 "write_block word range",
919 expected_end_code,
920 )?;
921 }
922 range_detail_or_warning(
923 format!(
924 "{address} returned 0x{expected_end_code:04X} on {} routes",
925 checked_routes.join("/")
926 ),
927 warnings,
928 )
929}
930
931fn expect_range_error<T>(
932 result: Result<T, SlmpError>,
933 operation: &str,
934 expected_end_code: u16,
935) -> Result<(), SlmpError> {
936 match result {
937 Ok(_) => Err(SlmpError::new(format!(
938 "{operation} unexpectedly succeeded; expected end_code=0x{expected_end_code:04X}"
939 ))),
940 Err(error) if error.end_code == Some(expected_end_code) => Ok(()),
941 Err(error) => Err(SlmpError::new(format!(
942 "{operation} expected end_code=0x{expected_end_code:04X}, got {error}"
943 ))),
944 }
945}
946
947fn expect_range_read_or_warn<T>(
948 result: Result<T, SlmpError>,
949 operation: &str,
950 expected_end_code: u16,
951 warnings: &mut Vec<String>,
952) -> Result<(), SlmpError> {
953 match result {
954 Ok(_) => {
955 warnings.push(format!(
956 "{operation} unexpectedly succeeded; expected end_code=0x{expected_end_code:04X}"
957 ));
958 Ok(())
959 }
960 Err(error) if error.end_code == Some(expected_end_code) => Ok(()),
961 Err(error) => Err(SlmpError::new(format!(
962 "{operation} expected end_code=0x{expected_end_code:04X}, got {error}"
963 ))),
964 }
965}
966
967fn range_detail_or_warning(detail: String, warnings: Vec<String>) -> Result<String, SlmpError> {
968 if warnings.is_empty() {
969 Ok(detail)
970 } else {
971 Err(SlmpError::new(format!(
972 "warning: {detail}; {}",
973 warnings.join("; ")
974 )))
975 }
976}
977
978fn expect_client_error<T>(result: Result<T, SlmpError>, operation: &str) -> Result<(), SlmpError> {
979 match result {
980 Ok(_) => Err(SlmpError::new(format!(
981 "{operation} unexpectedly succeeded"
982 ))),
983 Err(error) if error.end_code.is_none() => Ok(()),
984 Err(error) => Err(SlmpError::new(format!(
985 "{operation} should be rejected before transport, got {error}"
986 ))),
987 }
988}
989
990fn finish_with_restore(
991 test_result: Result<(), SlmpError>,
992 restores: &[(&str, Result<(), SlmpError>)],
993) -> Result<(), SlmpError> {
994 let restore_errors = restores
995 .iter()
996 .filter_map(|(label, result)| {
997 result
998 .as_ref()
999 .err()
1000 .map(|error| format!("{label}: {error}"))
1001 })
1002 .collect::<Vec<_>>();
1003 match (test_result, restore_errors.is_empty()) {
1004 (Ok(()), true) => Ok(()),
1005 (Ok(()), false) => Err(SlmpError::new(format!(
1006 "restore failed: {}",
1007 restore_errors.join("; ")
1008 ))),
1009 (Err(error), true) => Err(error),
1010 (Err(error), false) => Err(SlmpError::new(format!(
1011 "{error}; restore also failed: {}",
1012 restore_errors.join("; ")
1013 ))),
1014 }
1015}
1016
1017fn pack_bit_words(values: &[bool]) -> Vec<u16> {
1018 values
1019 .chunks(16)
1020 .map(|chunk| {
1021 let mut word = 0u16;
1022 for (index, value) in chunk.iter().enumerate() {
1023 if *value {
1024 word |= 1 << index;
1025 }
1026 }
1027 word
1028 })
1029 .collect()
1030}
1031
1032fn format_entry_address(entry: &SlmpDeviceRangeEntry, number: u32) -> String {
1033 let text = match entry.notation {
1034 SlmpDeviceRangeNotation::Decimal => number.to_string(),
1035 SlmpDeviceRangeNotation::Octal => format!("{number:o}"),
1036 SlmpDeviceRangeNotation::Hexadecimal => format!("{number:X}"),
1037 };
1038 format!("{}{}", entry.device, text)
1039}
1040
1041fn expect_u16(value: SlmpValue) -> Result<u16, SlmpError> {
1042 match value {
1043 SlmpValue::U16(value) => Ok(value),
1044 other => Err(SlmpError::new(format!("expected U16, got {other:?}"))),
1045 }
1046}
1047
1048fn expect_u32(value: SlmpValue) -> Result<u32, SlmpError> {
1049 match value {
1050 SlmpValue::U32(value) => Ok(value),
1051 other => Err(SlmpError::new(format!("expected U32, got {other:?}"))),
1052 }
1053}
1054
1055fn expect_f32(value: SlmpValue) -> Result<f32, SlmpError> {
1056 match value {
1057 SlmpValue::F32(value) => Ok(value),
1058 other => Err(SlmpError::new(format!("expected F32, got {other:?}"))),
1059 }
1060}
1061
1062fn alternate_u16(original: u16, candidate: u16) -> u16 {
1063 if original == candidate {
1064 candidate ^ 0xFFFF
1065 } else {
1066 candidate
1067 }
1068}
1069
1070fn alternate_u32(original: u32, candidate: u32) -> u32 {
1071 if original == candidate {
1072 candidate ^ 0xFFFF_FFFF
1073 } else {
1074 candidate
1075 }
1076}
1077
1078fn apply_family_default_devices(
1079 mut options: SlmpRouteValidationOptions,
1080 family: SlmpDeviceRangeFamily,
1081) -> SlmpRouteValidationOptions {
1082 if family == SlmpDeviceRangeFamily::IqF {
1083 if options.word_device == default_word_device() {
1084 options.word_device = "D1000".to_string();
1085 }
1086 if options.dword_device == default_dword_device() {
1087 options.dword_device = "D1002".to_string();
1088 }
1089 if options.float_device == default_float_device() {
1090 options.float_device = "D1004".to_string();
1091 }
1092 }
1093 options
1094}
1095
1096fn expected_range_end_code(family: SlmpDeviceRangeFamily) -> u16 {
1097 match family {
1098 SlmpDeviceRangeFamily::IqF => IQF_RANGE_END_CODE,
1099 _ => DEFAULT_RANGE_END_CODE,
1100 }
1101}
1102
1103fn route_capabilities(family: SlmpDeviceRangeFamily) -> RouteCapabilities {
1104 match family {
1105 SlmpDeviceRangeFamily::QCpu
1106 | SlmpDeviceRangeFamily::LCpu
1107 | SlmpDeviceRangeFamily::QnU
1108 | SlmpDeviceRangeFamily::QnUDV => RouteCapabilities {
1109 block: false,
1110 random: false,
1111 lz: false,
1112 },
1113 _ => RouteCapabilities {
1114 block: true,
1115 random: true,
1116 lz: true,
1117 },
1118 }
1119}
1120
1121fn default_word_device() -> String {
1122 "D9000".to_string()
1123}
1124
1125fn default_dword_device() -> String {
1126 "D9002".to_string()
1127}
1128
1129fn default_float_device() -> String {
1130 "D9004".to_string()
1131}
1132
1133fn default_bit_device() -> String {
1134 "M100".to_string()
1135}
1136
1137fn default_lz_device() -> String {
1138 "LZ0".to_string()
1139}
1140
1141fn default_range_error_devices() -> Vec<String> {
1142 ["X", "Y", "M", "D", "R", "ZR", "RD", "LZ", "SM", "SD"]
1143 .into_iter()
1144 .map(ToOwned::to_owned)
1145 .collect()
1146}
1147
1148#[allow(dead_code)]
1149fn _is_word_like_category(category: SlmpDeviceRangeCategory) -> bool {
1150 matches!(
1151 category,
1152 SlmpDeviceRangeCategory::Word
1153 | SlmpDeviceRangeCategory::Index
1154 | SlmpDeviceRangeCategory::FileRefresh
1155 | SlmpDeviceRangeCategory::TimerCounter
1156 )
1157}