Skip to main content

plc_comm_slmp/
route_validation.rs

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}