Skip to main content

plc_comm_slmp/
device_range_sample.rs

1use crate::address::SlmpAddress;
2use crate::client::SlmpClient;
3use crate::device_ranges::{SlmpDeviceRangeEntry, SlmpDeviceRangeFamily};
4use crate::error::SlmpError;
5use crate::helpers::{SlmpValue, read_typed, write_typed};
6use crate::model::{SlmpBlockRead, SlmpDeviceAddress, SlmpDeviceCode};
7use std::collections::BTreeSet;
8
9#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
10#[serde(rename_all = "camelCase")]
11pub struct SlmpDeviceRangeSampleOptions {
12    #[serde(default = "default_sample_points")]
13    pub sample_points: usize,
14    #[serde(default)]
15    pub only: Vec<String>,
16}
17
18impl Default for SlmpDeviceRangeSampleOptions {
19    fn default() -> Self {
20        Self {
21            sample_points: default_sample_points(),
22            only: Vec::new(),
23        }
24    }
25}
26
27impl SlmpDeviceRangeSampleOptions {
28    pub fn normalized(mut self) -> Self {
29        if self.sample_points == 0 {
30            self.sample_points = default_sample_points();
31        }
32        self.only = self
33            .only
34            .into_iter()
35            .map(|device| device.trim().to_ascii_uppercase())
36            .filter(|device| !device.is_empty())
37            .collect();
38        self
39    }
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
43#[serde(rename_all = "PascalCase")]
44pub enum SlmpDeviceRangeSampleValueKind {
45    Bit,
46    Word,
47    Dword,
48}
49
50#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
51#[serde(rename_all = "camelCase")]
52pub struct SlmpDeviceRangeSampleSummary {
53    pub passed: usize,
54    pub read_failed: usize,
55    pub write_failed: usize,
56    #[serde(default)]
57    pub readback_failed: usize,
58    pub restore_failed: usize,
59    pub skipped: usize,
60    pub unsupported: usize,
61    pub bit_blocks_passed: usize,
62    pub bit_blocks_failed: usize,
63}
64
65impl SlmpDeviceRangeSampleSummary {
66    pub fn is_success(&self) -> bool {
67        self.read_failed == 0
68            && self.write_failed == 0
69            && self.readback_failed == 0
70            && self.restore_failed == 0
71            && self.bit_blocks_failed == 0
72    }
73}
74
75#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
76#[serde(rename_all = "camelCase")]
77pub struct SlmpDeviceRangeSampleFailure {
78    pub address: String,
79    pub phase: String,
80    pub message: String,
81}
82
83#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
84#[serde(rename_all = "camelCase")]
85pub struct SlmpDeviceRangeSampleDeviceReport {
86    pub device: String,
87    pub address_range: Option<String>,
88    pub value_kind: Option<SlmpDeviceRangeSampleValueKind>,
89    pub sample_addresses: Vec<String>,
90    pub bit_block_addresses: Vec<String>,
91    pub untested_reason: Option<String>,
92    pub passed: usize,
93    pub read_failed: usize,
94    pub write_failed: usize,
95    #[serde(default)]
96    pub readback_failed: usize,
97    pub restore_failed: usize,
98    pub skipped: usize,
99    pub unsupported: usize,
100    pub bit_blocks_passed: usize,
101    pub bit_blocks_failed: usize,
102    pub failures: Vec<SlmpDeviceRangeSampleFailure>,
103}
104
105impl SlmpDeviceRangeSampleDeviceReport {
106    fn new(entry: &SlmpDeviceRangeEntry) -> Self {
107        Self {
108            device: entry.device.clone(),
109            address_range: entry.address_range.clone(),
110            value_kind: None,
111            sample_addresses: Vec::new(),
112            bit_block_addresses: Vec::new(),
113            untested_reason: None,
114            passed: 0,
115            read_failed: 0,
116            write_failed: 0,
117            readback_failed: 0,
118            restore_failed: 0,
119            skipped: 0,
120            unsupported: 0,
121            bit_blocks_passed: 0,
122            bit_blocks_failed: 0,
123            failures: Vec::new(),
124        }
125    }
126
127    fn fail(&mut self, address: String, phase: &str, message: String) {
128        self.failures.push(SlmpDeviceRangeSampleFailure {
129            address,
130            phase: phase.to_string(),
131            message,
132        });
133    }
134}
135
136#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
137#[serde(rename_all = "camelCase")]
138pub struct SlmpDeviceRangeSampleReport {
139    pub model: String,
140    pub family: SlmpDeviceRangeFamily,
141    pub sample_points: usize,
142    pub only: Vec<String>,
143    pub summary: SlmpDeviceRangeSampleSummary,
144    pub devices: Vec<SlmpDeviceRangeSampleDeviceReport>,
145}
146
147impl SlmpDeviceRangeSampleReport {
148    pub fn is_success(&self) -> bool {
149        self.summary.is_success()
150    }
151}
152
153pub async fn run_device_range_sample_compare(
154    client: &SlmpClient,
155    options: SlmpDeviceRangeSampleOptions,
156) -> Result<SlmpDeviceRangeSampleReport, SlmpError> {
157    let options = options.normalized();
158    let only = options.only.iter().cloned().collect::<BTreeSet<_>>();
159    let plc_family = client.plc_family().await;
160    let catalog = client.read_device_range_catalog().await?;
161    let range_family = catalog.family;
162    let mut report = SlmpDeviceRangeSampleReport {
163        model: catalog.model,
164        family: range_family,
165        sample_points: options.sample_points,
166        only: options.only,
167        summary: SlmpDeviceRangeSampleSummary::default(),
168        devices: Vec::new(),
169    };
170
171    for entry in catalog.entries {
172        if !only.is_empty() && !only.contains(&entry.device.to_ascii_uppercase()) {
173            continue;
174        }
175
176        let mut device_report = SlmpDeviceRangeSampleDeviceReport::new(&entry);
177        if !entry.supported {
178            report.summary.skipped += 1;
179            device_report.skipped += 1;
180            device_report.untested_reason = Some("unsupported by catalog".to_string());
181            report.devices.push(device_report);
182            continue;
183        }
184
185        let Some(upper_bound) = entry.upper_bound else {
186            report.summary.skipped += 1;
187            device_report.skipped += 1;
188            device_report.untested_reason = Some("open-ended range".to_string());
189            report.devices.push(device_report);
190            continue;
191        };
192
193        let Some(code) = SlmpDeviceCode::parse_prefix(&entry.device) else {
194            report.summary.unsupported += 1;
195            device_report.unsupported += 1;
196            device_report.untested_reason = Some("parser/client has no device code".to_string());
197            report.devices.push(device_report);
198            continue;
199        };
200
201        let kind = kind_for(&entry, code);
202        device_report.value_kind = Some(kind);
203        for number in sample_numbers(upper_bound, options.sample_points) {
204            let device = SlmpDeviceAddress::new(code, number);
205            let address = SlmpAddress::format_for_plc_family(device, plc_family);
206            device_report.sample_addresses.push(address.clone());
207
208            match exercise_point(client, device, &address, kind).await {
209                Ok(()) => {
210                    report.summary.passed += 1;
211                    device_report.passed += 1;
212                }
213                Err((phase, message)) if phase == "read" => {
214                    report.summary.read_failed += 1;
215                    device_report.read_failed += 1;
216                    device_report.fail(address.clone(), phase, message);
217                }
218                Err((phase, message)) if phase == "restore" => {
219                    report.summary.restore_failed += 1;
220                    device_report.restore_failed += 1;
221                    device_report.fail(address.clone(), phase, message);
222                }
223                Err((phase, message)) if phase == "readback" => {
224                    report.summary.readback_failed += 1;
225                    device_report.readback_failed += 1;
226                    device_report.fail(address.clone(), phase, message);
227                }
228                Err((phase, message)) => {
229                    report.summary.write_failed += 1;
230                    device_report.write_failed += 1;
231                    device_report.fail(address.clone(), phase, message);
232                }
233            }
234
235            if kind == SlmpDeviceRangeSampleValueKind::Bit
236                && supports_bit_block_route(range_family)
237                && supports_direct_bit_block(code)
238            {
239                let available_bits = upper_bound.saturating_sub(number) + 1;
240                let word_points = (available_bits / 16).min(1) as u16;
241                if word_points == 0 {
242                    continue;
243                }
244
245                let write_block = supports_direct_bit_block_write(code);
246                match exercise_bit_block(client, device, &address, word_points, write_block).await {
247                    Ok(()) => {
248                        report.summary.bit_blocks_passed += 1;
249                        device_report.bit_blocks_passed += 1;
250                        device_report.bit_block_addresses.push(address.clone());
251                    }
252                    Err((phase, message)) if phase == "restore" => {
253                        report.summary.restore_failed += 1;
254                        report.summary.bit_blocks_failed += 1;
255                        device_report.restore_failed += 1;
256                        device_report.bit_blocks_failed += 1;
257                        device_report.fail(address.clone(), "bit-block-restore", message);
258                    }
259                    Err((phase, message)) => {
260                        report.summary.bit_blocks_failed += 1;
261                        device_report.bit_blocks_failed += 1;
262                        device_report.fail(address.clone(), &format!("bit-block-{phase}"), message);
263                    }
264                }
265            }
266        }
267
268        report.devices.push(device_report);
269    }
270
271    Ok(report)
272}
273
274fn default_sample_points() -> usize {
275    10
276}
277
278fn sample_numbers(upper_bound: u32, count: usize) -> Vec<u32> {
279    if count == 0 {
280        return Vec::new();
281    }
282
283    if (upper_bound as u64) < count as u64 {
284        return (0..=upper_bound).collect();
285    }
286
287    let upper = upper_bound as u64;
288    let mut selected = BTreeSet::new();
289    for candidate in [
290        0,
291        upper,
292        upper / 2,
293        upper / 4,
294        (upper * 3) / 4,
295        upper / 8,
296        (upper * 3) / 8,
297        (upper * 5) / 8,
298        (upper * 7) / 8,
299        1,
300        upper.saturating_sub(1),
301    ] {
302        if selected.len() < count {
303            selected.insert(candidate as u32);
304        }
305    }
306
307    for index in 0..count {
308        if selected.len() >= count {
309            break;
310        }
311        let denominator = count.saturating_sub(1) as u64;
312        let candidate = if denominator == 0 {
313            0
314        } else {
315            ((index as u64 * upper) / denominator) as u32
316        };
317        selected.insert(candidate);
318    }
319
320    let mut cursor = 0u32;
321    while selected.len() < count {
322        selected.insert(cursor);
323        cursor = cursor.saturating_add(1);
324    }
325
326    selected.into_iter().collect()
327}
328
329fn kind_for(entry: &SlmpDeviceRangeEntry, code: SlmpDeviceCode) -> SlmpDeviceRangeSampleValueKind {
330    if entry.is_bit_device {
331        SlmpDeviceRangeSampleValueKind::Bit
332    } else if matches!(
333        code,
334        SlmpDeviceCode::LTN | SlmpDeviceCode::LSTN | SlmpDeviceCode::LCN | SlmpDeviceCode::LZ
335    ) {
336        SlmpDeviceRangeSampleValueKind::Dword
337    } else {
338        SlmpDeviceRangeSampleValueKind::Word
339    }
340}
341
342fn dtype_for(kind: SlmpDeviceRangeSampleValueKind) -> &'static str {
343    match kind {
344        SlmpDeviceRangeSampleValueKind::Bit => "BIT",
345        SlmpDeviceRangeSampleValueKind::Word => "U",
346        SlmpDeviceRangeSampleValueKind::Dword => "D",
347    }
348}
349
350fn supports_direct_bit_block(code: SlmpDeviceCode) -> bool {
351    !matches!(
352        code,
353        SlmpDeviceCode::LTS
354            | SlmpDeviceCode::LTC
355            | SlmpDeviceCode::LSTS
356            | SlmpDeviceCode::LSTC
357            | SlmpDeviceCode::LCS
358            | SlmpDeviceCode::LCC
359    )
360}
361
362fn supports_direct_bit_block_write(code: SlmpDeviceCode) -> bool {
363    supports_direct_bit_block(code) && !matches!(code, SlmpDeviceCode::SM)
364}
365
366fn supports_bit_block_route(family: SlmpDeviceRangeFamily) -> bool {
367    !matches!(
368        family,
369        SlmpDeviceRangeFamily::QCpu
370            | SlmpDeviceRangeFamily::LCpu
371            | SlmpDeviceRangeFamily::QnU
372            | SlmpDeviceRangeFamily::QnUDV
373    )
374}
375
376fn seeded_u16(label: &str, salt: u32) -> u16 {
377    let mut hash = 0x811C9DC5u32 ^ salt;
378    for byte in label.as_bytes() {
379        hash ^= u32::from(*byte);
380        hash = hash.wrapping_mul(0x0100_0193);
381    }
382    let value = ((hash & 0xFFFF) as u16) | 1;
383    if value == 0 { 1 } else { value }
384}
385
386fn seeded_u32(label: &str, salt: u32) -> u32 {
387    let high = seeded_u16(label, salt) as u32;
388    let low = seeded_u16(label, salt ^ 0xA5A5_5A5A) as u32;
389    (high << 16) | low
390}
391
392fn test_values(
393    address: &str,
394    original: &SlmpValue,
395    kind: SlmpDeviceRangeSampleValueKind,
396) -> (SlmpValue, SlmpValue) {
397    match (kind, original) {
398        (SlmpDeviceRangeSampleValueKind::Bit, SlmpValue::Bool(value)) => {
399            (SlmpValue::Bool(!*value), SlmpValue::Bool(*value))
400        }
401        (SlmpDeviceRangeSampleValueKind::Word, SlmpValue::U16(original)) => {
402            let mut a = seeded_u16(address, 0x1111);
403            let mut b = seeded_u16(address, 0x2222);
404            if a == *original {
405                a ^= 0x00FF;
406            }
407            if b == a {
408                b ^= 0xFF00;
409            }
410            (SlmpValue::U16(a), SlmpValue::U16(b))
411        }
412        (SlmpDeviceRangeSampleValueKind::Dword, SlmpValue::U32(original)) => {
413            let mut a = seeded_u32(address, 0x3333);
414            let mut b = seeded_u32(address, 0x4444);
415            if a == *original {
416                a ^= 0x0000_FFFF;
417            }
418            if b == a {
419                b ^= 0xFFFF_0000;
420            }
421            (SlmpValue::U32(a), SlmpValue::U32(b))
422        }
423        _ => {
424            let a = seeded_u16(address, 0x5555);
425            let b = seeded_u16(address, 0x6666);
426            (SlmpValue::U16(a), SlmpValue::U16(b))
427        }
428    }
429}
430
431async fn read_value(
432    client: &SlmpClient,
433    device: SlmpDeviceAddress,
434    kind: SlmpDeviceRangeSampleValueKind,
435) -> Result<SlmpValue, SlmpError> {
436    read_typed(client, device, dtype_for(kind)).await
437}
438
439async fn write_value(
440    client: &SlmpClient,
441    device: SlmpDeviceAddress,
442    kind: SlmpDeviceRangeSampleValueKind,
443    value: &SlmpValue,
444) -> Result<(), SlmpError> {
445    write_typed(client, device, dtype_for(kind), value).await
446}
447
448async fn assert_value(
449    client: &SlmpClient,
450    device: SlmpDeviceAddress,
451    kind: SlmpDeviceRangeSampleValueKind,
452    expected: &SlmpValue,
453) -> Result<(), (&'static str, String)> {
454    let observed = read_value(client, device, kind)
455        .await
456        .map_err(|error| ("readback", error.to_string()))?;
457    if &observed != expected {
458        return Err((
459            "readback",
460            format!("readback mismatch: expected={expected:?} observed={observed:?}"),
461        ));
462    }
463    Ok(())
464}
465
466async fn exercise_point(
467    client: &SlmpClient,
468    device: SlmpDeviceAddress,
469    address: &str,
470    kind: SlmpDeviceRangeSampleValueKind,
471) -> Result<(), (&'static str, String)> {
472    let original = read_value(client, device, kind)
473        .await
474        .map_err(|error| ("read", error.to_string()))?;
475    let (value_a, value_b) = test_values(address, &original, kind);
476
477    let test_result: Result<(), (&'static str, String)> = async {
478        write_value(client, device, kind, &value_a)
479            .await
480            .map_err(|error| ("write", error.to_string()))?;
481        assert_value(client, device, kind, &value_a).await?;
482        write_value(client, device, kind, &value_b)
483            .await
484            .map_err(|error| ("write", error.to_string()))?;
485        assert_value(client, device, kind, &value_b).await?;
486        Ok(())
487    }
488    .await;
489
490    let restore_result = write_value(client, device, kind, &original).await;
491    match (test_result, restore_result) {
492        (Ok(()), Ok(())) => Ok(()),
493        (Ok(()), Err(error)) => Err(("restore", error.to_string())),
494        (Err((phase, message)), Ok(())) => Err((phase, message)),
495        (Err((_phase, test_error)), Err(restore_error)) => Err((
496            "restore",
497            format!("{test_error}; restore also failed: {restore_error}"),
498        )),
499    }
500}
501
502async fn read_bit_block_checked(
503    client: &SlmpClient,
504    device: SlmpDeviceAddress,
505    word_points: u16,
506) -> Result<Vec<bool>, SlmpError> {
507    let bit_points = word_points * 16;
508    let direct = client.read_bits(device, bit_points).await?;
509    let block = client
510        .read_block(
511            &[],
512            &[SlmpBlockRead {
513                device,
514                points: word_points,
515            }],
516        )
517        .await?;
518    let packed = pack_bit_words(&direct);
519    if packed != block.bit_values {
520        return Err(SlmpError::new(format!(
521            "bit block mismatch: read_bits_packed={packed:?} read_block={:?}",
522            block.bit_values
523        )));
524    }
525    Ok(direct)
526}
527
528fn pack_bit_words(values: &[bool]) -> Vec<u16> {
529    values
530        .chunks(16)
531        .map(|chunk| {
532            let mut word = 0u16;
533            for (index, value) in chunk.iter().enumerate() {
534                if *value {
535                    word |= 1 << index;
536                }
537            }
538            word
539        })
540        .collect()
541}
542
543async fn exercise_bit_block(
544    client: &SlmpClient,
545    device: SlmpDeviceAddress,
546    address: &str,
547    word_points: u16,
548    write: bool,
549) -> Result<(), (&'static str, String)> {
550    let original = read_bit_block_checked(client, device, word_points)
551        .await
552        .map_err(|error| ("read", error.to_string()))?;
553    if !write {
554        return Ok(());
555    }
556    let value_a = original.iter().map(|value| !*value).collect::<Vec<_>>();
557    let value_b = (0..original.len())
558        .map(|index| ((device.number as usize + index) % 2) == 0)
559        .collect::<Vec<_>>();
560
561    let test_result: Result<(), SlmpError> = async {
562        client.write_bits(device, &value_a).await?;
563        let observed = read_bit_block_checked(client, device, word_points).await?;
564        if observed != value_a {
565            return Err(SlmpError::new(format!(
566                "{address} bit block write A mismatch: expected={value_a:?} observed={observed:?}"
567            )));
568        }
569        client.write_bits(device, &value_b).await?;
570        let observed = read_bit_block_checked(client, device, word_points).await?;
571        if observed != value_b {
572            return Err(SlmpError::new(format!(
573                "{address} bit block write B mismatch: expected={value_b:?} observed={observed:?}"
574            )));
575        }
576        Ok(())
577    }
578    .await;
579
580    let restore_result = client.write_bits(device, &original).await;
581    match (test_result, restore_result) {
582        (Ok(()), Ok(())) => Ok(()),
583        (Ok(()), Err(error)) => Err(("restore", error.to_string())),
584        (Err(error), Ok(())) => Err(("write", error.to_string())),
585        (Err(test_error), Err(restore_error)) => Err((
586            "restore",
587            format!("{test_error}; restore also failed: {restore_error}"),
588        )),
589    }
590}
591
592#[cfg(test)]
593mod tests {
594    use super::{SlmpDeviceRangeSampleOptions, sample_numbers};
595
596    #[test]
597    fn sample_numbers_include_edges_and_middle() {
598        let samples = sample_numbers(100, 10);
599        assert_eq!(samples.len(), 10);
600        assert!(samples.contains(&0));
601        assert!(samples.contains(&50));
602        assert!(samples.contains(&100));
603    }
604
605    #[test]
606    fn options_normalize_zero_sample_count_to_default() {
607        let options = SlmpDeviceRangeSampleOptions {
608            sample_points: 0,
609            only: vec![" d ".to_string(), "".to_string(), "x".to_string()],
610        }
611        .normalized();
612
613        assert_eq!(options.sample_points, 10);
614        assert_eq!(options.only, vec!["D", "X"]);
615    }
616}