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}