Skip to main content

plc_comm_hostlink/
device_ranges.rs

1use crate::address::{default_format_by_device_type, is_direct_bit_device_type};
2use crate::error::HostLinkError;
3use crate::model::KvModelInfo;
4use std::sync::OnceLock;
5
6const RANGE_CSV_DATA: &str = r#"DeviceType,Base,KV-NANO,KV-NANO(XYM),KV-3000/5000,KV-3000/5000(XYM),KV-7000,KV-7000(XYM),KV-8000,KV-8000(XYM),KV-X500,KV-X500(XYM)
7R,10,R00000-R59915,"X0-599F,Y0-599F",R00000-R99915,"X0-999F,Y0-999F",R00000-R199915,"X0-1999F,Y0-1999F",R00000-R199915,"X0-1999F,Y0-1999F",R00000-R199915,"X0-1999F,Y0-1999F"
8B,16,B0000-B1FFF,B0000-B1FFF,B0000-B3FFF,B0000-B3FFF,B0000-B7FFF,B0000-B7FFF,B0000-B7FFF,B0000-B7FFF,B0000-B7FFF,B0000-B7FFF
9MR,10,MR00000-MR59915,M0-9599,MR00000-MR99915,M0-15999,MR000000-MR399915,M000000-M63999,MR000000-MR399915,M000000-M63999,MR000000-MR399915,M000000-M63999
10LR,10,LR00000-LR19915,L0-3199,LR00000-LR99915,L0-15999,LR00000-LR99915,L00000-L15999,LR00000-LR99915,L00000-L15999,LR00000-LR99915,L00000-L15999
11CR,10,CR0000-CR8915,CR0000-CR8915,CR0000-CR3915,CR0000-CR3915,CR0000-CR7915,CR0000-CR7915,CR0000-CR7915,CR0000-CR7915,CR0000-CR7915,CR0000-CR7915
12CM,10,CM0000-CM8999,CM0000-CM8999,CM0000-CM5999,CM0000-CM5999,CM0000-CM5999,CM0000-CM5999,CM0000-CM7599,CM0000-CM7599,CM0000-CM7599,CM0000-CM7599
13T,10,T0000-T0511,T0000-T0511,T0000-T3999,T0000-T3999,T0000-T3999,T0000-T3999,T0000-T3999,T0000-T3999,T0000-T3999,T0000-T3999
14C,10,C0000-C0255,C0000-C0255,C0000-C3999,C0000-C3999,C0000-C3999,C0000-C3999,C0000-C3999,C0000-C3999,C0000-C3999,C0000-C3999
15DM,10,DM00000-DM32767,D0-32767,DM00000-DM65534,D0-65534,DM00000-DM65534,D00000-D65534,DM00000-DM65534,D00000-D65534,DM00000-DM65534,D00000-D65534
16EM,10,-,-,EM00000-EM65534,E0-65534,EM00000-EM65534,E00000-E65534,EM00000-EM65534,E00000-E65534,EM00000-EM65534,E00000-E65534
17FM,10,-,-,FM00000-FM32767,F0-32767,FM00000-FM32767,F00000-F32767,FM00000-FM32767,F00000-F32767,FM00000-FM32767,F00000-F32767
18ZF,10,-,-,ZF000000-ZF131071,ZF000000-ZF131071,ZF000000-ZF524287,ZF000000-ZF524287,ZF000000-ZF524287,ZF000000-ZF524287,ZF000000-ZF524287,ZF000000-ZF524287
19W,16,W0000-W3FFF,W0000-W3FFF,W0000-W3FFF,W0000-W3FFF,W0000-W7FFF,W0000-W7FFF,W0000-W7FFF,W0000-W7FFF,W0000-W7FFF,W0000-W7FFF
20TM,10,TM000-TM511,TM000-TM511,TM000-TM511,TM000-TM511,TM000-TM511,TM000-TM511,TM000-TM511,TM000-TM511,TM000-TM511,TM000-TM511
21VM,10,VM0-9499,VM0-9499,VM0-49999,VM0-49999,VM0-63999,VM0-63999,VM0-589823,VM0-589823,-,-
22VB,16,VB0-1FFF,VB0-1FFF,VB0-3FFF,VB0-3FFF,VB0-F9FF,VB0-F9FF,VB0-F9FF,VB0-F9FF,-,-
23Z,10,Z1-12,Z1-12,Z1-12,Z1-12,Z1-12,Z1-12,Z1-12,Z1-12,-,-
24CTH,10,CTH0-3,CTH0-3,CTH0-1,CTH0-3,-,-,-,-,-,-
25CTC,10,CTC0-7,CTC0-7,CTC0-3,CTC0-3,-,-,-,-,-,-
26AT,10,-,-,AT0-7,AT0-7,AT0-7,AT0-7,AT0-7,AT0-7,-,-
27"#;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum KvDeviceRangeNotation {
31    Decimal,
32    Hexadecimal,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum KvDeviceRangeCategory {
37    Bit,
38    Word,
39    TimerCounter,
40    Index,
41    FileRefresh,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct KvDeviceRangeSegment {
46    pub device: String,
47    pub category: KvDeviceRangeCategory,
48    pub is_bit_device: bool,
49    pub notation: KvDeviceRangeNotation,
50    pub lower_bound: u32,
51    pub upper_bound: Option<u32>,
52    pub point_count: Option<u32>,
53    pub address_range: String,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct KvDeviceRangeEntry {
58    pub device: String,
59    pub device_type: String,
60    pub category: KvDeviceRangeCategory,
61    pub is_bit_device: bool,
62    pub notation: KvDeviceRangeNotation,
63    pub supported: bool,
64    pub lower_bound: u32,
65    pub upper_bound: Option<u32>,
66    pub point_count: Option<u32>,
67    pub address_range: Option<String>,
68    pub source: String,
69    pub notes: Option<String>,
70    pub segments: Vec<KvDeviceRangeSegment>,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct KvDeviceRangeCatalog {
75    pub model: String,
76    pub model_code: String,
77    pub has_model_code: bool,
78    pub requested_model: String,
79    pub resolved_model: String,
80    pub entries: Vec<KvDeviceRangeEntry>,
81}
82
83impl KvDeviceRangeCatalog {
84    pub fn entry(&self, device_type: &str) -> Option<&KvDeviceRangeEntry> {
85        let wanted = device_type.trim();
86        self.entries
87            .iter()
88            .find(|entry| entry.device_type.eq_ignore_ascii_case(wanted))
89            .or_else(|| {
90                self.entries
91                    .iter()
92                    .find(|entry| entry.device.eq_ignore_ascii_case(wanted))
93            })
94            .or_else(|| {
95                self.entries.iter().find(|entry| {
96                    entry
97                        .segments
98                        .iter()
99                        .any(|segment| segment.device.eq_ignore_ascii_case(wanted))
100                })
101            })
102    }
103}
104
105pub fn device_range_catalog_for_model(
106    model: impl AsRef<str>,
107) -> Result<KvDeviceRangeCatalog, HostLinkError> {
108    build_catalog(model.as_ref(), None)
109}
110
111pub(crate) fn device_range_catalog_for_query_model(
112    model: &KvModelInfo,
113) -> Result<KvDeviceRangeCatalog, HostLinkError> {
114    build_catalog(&model.model, Some(&model.code))
115}
116
117fn build_catalog(
118    requested_model: &str,
119    model_code: Option<&str>,
120) -> Result<KvDeviceRangeCatalog, HostLinkError> {
121    let requested_model = requested_model.trim().to_owned();
122    if requested_model.is_empty() {
123        return Err(HostLinkError::protocol("Model name must not be empty"));
124    }
125
126    let table = range_table()?;
127    let resolved_model = resolve_model_column(table, &requested_model)?;
128    let model_index = table
129        .model_headers
130        .iter()
131        .position(|header| header == resolved_model)
132        .ok_or_else(|| {
133            HostLinkError::protocol(format!(
134                "Resolved model column '{resolved_model}' was not found in the embedded device range table."
135            ))
136        })?;
137
138    let entries = table
139        .rows
140        .iter()
141        .map(|row| build_entry(row, model_index, resolved_model))
142        .collect::<Vec<_>>();
143
144    Ok(KvDeviceRangeCatalog {
145        model: resolved_model.to_owned(),
146        model_code: model_code.unwrap_or_default().to_owned(),
147        has_model_code: model_code.is_some(),
148        requested_model,
149        resolved_model: resolved_model.to_owned(),
150        entries,
151    })
152}
153
154pub fn available_device_range_models() -> Vec<String> {
155    range_table()
156        .map(|table| table.model_headers.clone())
157        .unwrap_or_default()
158}
159
160#[derive(Debug, Clone)]
161struct RangeTable {
162    model_headers: Vec<String>,
163    rows: Vec<RangeRow>,
164}
165
166#[derive(Debug, Clone)]
167struct RangeRow {
168    device_type: String,
169    notation: KvDeviceRangeNotation,
170    ranges: Vec<String>,
171}
172
173static RANGE_TABLE: OnceLock<Result<RangeTable, String>> = OnceLock::new();
174
175fn range_table() -> Result<&'static RangeTable, HostLinkError> {
176    RANGE_TABLE
177        .get_or_init(|| parse_range_table().map_err(|error| error.to_string()))
178        .as_ref()
179        .map_err(|error| HostLinkError::protocol(error.clone()))
180}
181
182fn parse_range_table() -> Result<RangeTable, HostLinkError> {
183    let mut lines = RANGE_CSV_DATA
184        .lines()
185        .filter(|line| !line.trim().is_empty());
186    let header_line = lines
187        .next()
188        .ok_or_else(|| HostLinkError::protocol("Embedded device range table is empty"))?;
189    let headers = parse_csv_line(header_line)?;
190    if headers.len() < 3 {
191        return Err(HostLinkError::protocol(
192            "Embedded device range table must contain at least DeviceType, Base, and one model column",
193        ));
194    }
195
196    let model_headers = headers[2..]
197        .iter()
198        .map(|header| header.trim().to_owned())
199        .collect::<Vec<_>>();
200    let mut rows = Vec::new();
201
202    for line in lines {
203        let fields = parse_csv_line(line)?;
204        if fields.len() != headers.len() {
205            return Err(HostLinkError::protocol(format!(
206                "Embedded device range row has {} columns but {} were expected: {line}",
207                fields.len(),
208                headers.len()
209            )));
210        }
211
212        rows.push(RangeRow {
213            device_type: fields[0].trim().to_owned(),
214            notation: notation_from_base(&fields[1])?,
215            ranges: fields[2..]
216                .iter()
217                .map(|value| value.trim().to_owned())
218                .collect(),
219        });
220    }
221
222    Ok(RangeTable {
223        model_headers,
224        rows,
225    })
226}
227
228fn parse_csv_line(line: &str) -> Result<Vec<String>, HostLinkError> {
229    let mut fields = Vec::new();
230    let mut current = String::new();
231    let mut chars = line.trim_end_matches('\r').chars().peekable();
232    let mut in_quotes = false;
233
234    while let Some(ch) = chars.next() {
235        match ch {
236            '"' => {
237                if in_quotes && chars.peek() == Some(&'"') {
238                    current.push('"');
239                    chars.next();
240                } else {
241                    in_quotes = !in_quotes;
242                }
243            }
244            ',' if !in_quotes => {
245                fields.push(current);
246                current = String::new();
247            }
248            _ => current.push(ch),
249        }
250    }
251
252    if in_quotes {
253        return Err(HostLinkError::protocol(format!(
254            "Embedded device range table contains an unterminated quoted field: {line}"
255        )));
256    }
257
258    fields.push(current);
259    Ok(fields)
260}
261
262fn notation_from_base(base_text: &str) -> Result<KvDeviceRangeNotation, HostLinkError> {
263    let normalized = base_text.trim();
264    if normalized.starts_with("10") {
265        Ok(KvDeviceRangeNotation::Decimal)
266    } else if normalized.starts_with("16") {
267        Ok(KvDeviceRangeNotation::Hexadecimal)
268    } else {
269        Err(HostLinkError::protocol(format!(
270            "Unsupported base cell '{base_text}' in the embedded device range table"
271        )))
272    }
273}
274
275fn build_entry(row: &RangeRow, model_index: usize, resolved_model: &str) -> KvDeviceRangeEntry {
276    let range_text = row.ranges[model_index].trim();
277    let supported = !range_text.is_empty() && range_text != "-";
278    let address_range = supported.then(|| range_text.to_owned());
279    let segments = address_range
280        .as_deref()
281        .map(|text| parse_segments(row, text))
282        .unwrap_or_default();
283    let primary_device = primary_device_name(row, &segments);
284    let (category, is_bit_device) = device_metadata(&primary_device);
285    let notation = entry_notation(row.notation, &segments);
286    let (lower_bound, upper_bound, point_count) = summarize_entry_bounds(&segments);
287    let notes = entry_notes(&segments);
288
289    KvDeviceRangeEntry {
290        device: primary_device,
291        device_type: row.device_type.clone(),
292        category,
293        is_bit_device,
294        notation,
295        supported,
296        lower_bound,
297        upper_bound,
298        point_count,
299        address_range,
300        source: format!("Embedded device range table ({resolved_model})"),
301        notes,
302        segments,
303    }
304}
305
306fn parse_segments(row: &RangeRow, range_text: &str) -> Vec<KvDeviceRangeSegment> {
307    range_text
308        .split(',')
309        .map(str::trim)
310        .filter(|segment| !segment.is_empty())
311        .map(|segment| {
312            let device = segment_device(segment);
313            let device = if device.is_empty() {
314                row.device_type.clone()
315            } else {
316                device
317            };
318            let (category, is_bit_device) = device_metadata(&device);
319            let notation = notation_for_device(row.notation, &device);
320            let (lower_bound, upper_bound, point_count) =
321                parse_segment_bounds(segment, notation, &device);
322            KvDeviceRangeSegment {
323                device,
324                category,
325                is_bit_device,
326                notation,
327                lower_bound,
328                upper_bound,
329                point_count,
330                address_range: segment.to_owned(),
331            }
332        })
333        .collect()
334}
335
336fn segment_device(segment: &str) -> String {
337    segment
338        .chars()
339        .take_while(|ch| ch.is_ascii_alphabetic())
340        .collect::<String>()
341}
342
343fn primary_device_name(row: &RangeRow, segments: &[KvDeviceRangeSegment]) -> String {
344    let unique_devices = segments.iter().map(|segment| segment.device.as_str()).fold(
345        Vec::<&str>::new(),
346        |mut devices, device| {
347            if !devices
348                .iter()
349                .any(|existing| existing.eq_ignore_ascii_case(device))
350            {
351                devices.push(device);
352            }
353            devices
354        },
355    );
356    if unique_devices.len() == 1 {
357        unique_devices[0].to_owned()
358    } else {
359        row.device_type.clone()
360    }
361}
362
363fn summarize_entry_bounds(segments: &[KvDeviceRangeSegment]) -> (u32, Option<u32>, Option<u32>) {
364    let Some(first) = segments.first() else {
365        return (0, None, None);
366    };
367    let all_same = segments.iter().skip(1).all(|segment| {
368        segment.lower_bound == first.lower_bound
369            && segment.upper_bound == first.upper_bound
370            && segment.point_count == first.point_count
371    });
372    if all_same {
373        (first.lower_bound, first.upper_bound, first.point_count)
374    } else {
375        (first.lower_bound, None, None)
376    }
377}
378
379fn entry_notation(
380    fallback: KvDeviceRangeNotation,
381    segments: &[KvDeviceRangeSegment],
382) -> KvDeviceRangeNotation {
383    let Some(first) = segments.first() else {
384        return fallback;
385    };
386    if segments
387        .iter()
388        .skip(1)
389        .all(|segment| segment.notation == first.notation)
390    {
391        first.notation
392    } else {
393        fallback
394    }
395}
396
397fn entry_notes(segments: &[KvDeviceRangeSegment]) -> Option<String> {
398    (segments.len() > 1).then(|| {
399        "Published address range expands to multiple alias devices; inspect segments.".to_owned()
400    })
401}
402
403fn parse_segment_bounds(
404    segment: &str,
405    notation: KvDeviceRangeNotation,
406    default_device: &str,
407) -> (u32, Option<u32>, Option<u32>) {
408    let Some((start_text, end_text)) = segment.split_once('-') else {
409        return (0, None, None);
410    };
411    let start = parse_segment_number(start_text, notation, default_device);
412    let end = parse_segment_number(end_text, notation, default_device);
413    let point_count = start
414        .zip(end)
415        .and_then(|(lower, upper)| upper.checked_sub(lower))
416        .and_then(|distance| distance.checked_add(1));
417    (start.unwrap_or(0), end, point_count)
418}
419
420fn parse_segment_number(
421    text: &str,
422    notation: KvDeviceRangeNotation,
423    default_device: &str,
424) -> Option<u32> {
425    let normalized = text.trim();
426    let trimmed = normalized
427        .strip_prefix(default_device)
428        .unwrap_or(normalized)
429        .trim_start_matches(|ch: char| ch.is_ascii_alphabetic());
430    if trimmed.is_empty() {
431        return None;
432    }
433    if matches!(default_device, "X" | "Y") {
434        return parse_xym_segment_number(trimmed);
435    }
436    match notation {
437        KvDeviceRangeNotation::Decimal => trimmed.parse().ok(),
438        KvDeviceRangeNotation::Hexadecimal => u32::from_str_radix(trimmed, 16).ok(),
439    }
440}
441
442fn parse_xym_segment_number(text: &str) -> Option<u32> {
443    let (bank_text, bit_text) = text.split_at(text.len().saturating_sub(1));
444    if !bank_text.bytes().all(|byte| byte.is_ascii_digit()) {
445        return None;
446    }
447    let bank = if bank_text.is_empty() {
448        0
449    } else {
450        bank_text.parse::<u32>().ok()?
451    };
452    let bit = u32::from_str_radix(bit_text, 16).ok()?;
453    bank.checked_mul(16)?.checked_add(bit)
454}
455
456fn device_metadata(device_type: &str) -> (KvDeviceRangeCategory, bool) {
457    if matches!(device_type, "Z") {
458        return (KvDeviceRangeCategory::Index, false);
459    }
460    if matches!(device_type, "ZF") {
461        return (KvDeviceRangeCategory::FileRefresh, false);
462    }
463    if matches!(device_type, "T" | "C" | "AT" | "CTH" | "CTC") {
464        return (KvDeviceRangeCategory::TimerCounter, false);
465    }
466    if is_direct_bit_device_type(device_type) {
467        return (KvDeviceRangeCategory::Bit, true);
468    }
469    match default_format_by_device_type(device_type) {
470        "" => (KvDeviceRangeCategory::Bit, true),
471        _ => (KvDeviceRangeCategory::Word, false),
472    }
473}
474
475fn notation_for_device(
476    fallback: KvDeviceRangeNotation,
477    device_type: &str,
478) -> KvDeviceRangeNotation {
479    if matches!(device_type, "B" | "W" | "VB" | "X" | "Y") {
480        KvDeviceRangeNotation::Hexadecimal
481    } else {
482        fallback
483    }
484}
485
486fn resolve_model_column<'a>(
487    table: &'a RangeTable,
488    requested_model: &str,
489) -> Result<&'a str, HostLinkError> {
490    let normalized = normalize_model_key(requested_model);
491    if let Some(header) = direct_model_match(table, &normalized) {
492        return Ok(header);
493    }
494
495    let wants_xym = normalized.ends_with("(XYM)");
496    let base_model = normalized.strip_suffix("(XYM)").unwrap_or(&normalized);
497    let resolved_family = match base_model {
498        value if value.starts_with("KV-NANO") || value.starts_with("KV-N") => "KV-NANO",
499        value
500            if value.starts_with("KV-3000")
501                || value.starts_with("KV-5000")
502                || value.starts_with("KV-5500") =>
503        {
504            "KV-3000/5000"
505        }
506        value
507            if value.starts_with("KV-7000")
508                || value.starts_with("KV-7300")
509                || value.starts_with("KV-7500") =>
510        {
511            "KV-7000"
512        }
513        value if value.starts_with("KV-8000") => "KV-8000",
514        value if value.starts_with("KV-X5") || value.starts_with("KV-X3") => "KV-X500",
515        _ => {
516            let supported = table.model_headers.join(", ");
517            return Err(HostLinkError::protocol(format!(
518                "Unsupported model '{requested_model}'. Supported range models: {supported}."
519            )));
520        }
521    };
522
523    let resolved_key = if wants_xym {
524        format!("{resolved_family}(XYM)")
525    } else {
526        resolved_family.to_owned()
527    };
528
529    direct_model_match(table, &resolved_key).ok_or_else(|| {
530        HostLinkError::protocol(format!(
531            "Resolved model '{resolved_key}' was not found in the embedded device range table."
532        ))
533    })
534}
535
536fn direct_model_match<'a>(table: &'a RangeTable, normalized: &str) -> Option<&'a str> {
537    table
538        .model_headers
539        .iter()
540        .find(|header| normalize_model_key(header) == normalized)
541        .map(String::as_str)
542}
543
544fn normalize_model_key(text: &str) -> String {
545    text.trim()
546        .trim_end_matches('\0')
547        .chars()
548        .filter(|ch| !ch.is_whitespace())
549        .collect::<String>()
550        .to_ascii_uppercase()
551}
552
553#[cfg(test)]
554mod tests {
555    use super::{
556        KvDeviceRangeCategory, KvDeviceRangeNotation, available_device_range_models,
557        device_range_catalog_for_model, normalize_model_key,
558    };
559
560    #[test]
561    fn available_models_include_xym_columns_from_csv() {
562        let models = available_device_range_models();
563        assert!(models.iter().any(|model| model == "KV-7000"));
564        assert!(models.iter().any(|model| model == "KV-7000(XYM)"));
565    }
566
567    #[test]
568    fn resolves_known_runtime_model_names_to_csv_family_columns() {
569        let catalog = device_range_catalog_for_model("KV-8000A").unwrap();
570        assert_eq!(catalog.model, "KV-8000");
571        assert_eq!(catalog.model_code, "");
572        assert!(!catalog.has_model_code);
573        assert_eq!(catalog.resolved_model, "KV-8000");
574        assert_eq!(
575            catalog.entry("DM").unwrap().address_range.as_deref(),
576            Some("DM00000-DM65534")
577        );
578
579        let x_catalog = device_range_catalog_for_model("KV-X530").unwrap();
580        assert_eq!(x_catalog.resolved_model, "KV-X500");
581        assert_eq!(
582            x_catalog.entry("ZF").unwrap().address_range.as_deref(),
583            Some("ZF000000-ZF524287")
584        );
585
586        let tm = catalog.entry("TM").unwrap();
587        assert_eq!(tm.category, KvDeviceRangeCategory::Word);
588        assert!(!tm.is_bit_device);
589        assert_eq!(tm.address_range.as_deref(), Some("TM000-TM511"));
590    }
591
592    #[test]
593    fn xym_catalog_splits_multi_device_ranges_into_segments() {
594        let catalog = device_range_catalog_for_model("KV-3000/5000(XYM)").unwrap();
595        let entry = catalog.entry("R").unwrap();
596
597        assert_eq!(entry.device, "R");
598        assert_eq!(entry.category, KvDeviceRangeCategory::Bit);
599        assert!(entry.is_bit_device);
600        assert_eq!(entry.notation, KvDeviceRangeNotation::Hexadecimal);
601        assert_eq!(entry.lower_bound, 0);
602        assert_eq!(entry.upper_bound, Some(999 * 16 + 15));
603        assert_eq!(entry.point_count, Some(1_000 * 16));
604        assert_eq!(entry.address_range.as_deref(), Some("X0-999F,Y0-999F"));
605        assert!(
606            entry
607                .notes
608                .as_deref()
609                .unwrap()
610                .contains("multiple alias devices")
611        );
612        assert_eq!(entry.segments.len(), 2);
613        assert_eq!(entry.segments[0].device, "X");
614        assert_eq!(
615            entry.segments[0].notation,
616            KvDeviceRangeNotation::Hexadecimal
617        );
618        assert_eq!(entry.segments[0].lower_bound, 0);
619        assert_eq!(entry.segments[0].upper_bound, Some(999 * 16 + 15));
620        assert_eq!(entry.segments[0].point_count, Some(1_000 * 16));
621        assert_eq!(entry.segments[0].address_range, "X0-999F");
622        assert_eq!(entry.segments[1].device, "Y");
623        assert_eq!(
624            entry.segments[1].notation,
625            KvDeviceRangeNotation::Hexadecimal
626        );
627        assert_eq!(entry.segments[1].lower_bound, 0);
628        assert_eq!(entry.segments[1].upper_bound, Some(999 * 16 + 15));
629        assert_eq!(entry.segments[1].point_count, Some(1_000 * 16));
630        assert_eq!(entry.segments[1].address_range, "Y0-999F");
631        assert_eq!(catalog.entry("X").unwrap().device_type, "R");
632
633        let kv8000 = device_range_catalog_for_model("KV-8000(XYM)").unwrap();
634        let r = kv8000.entry("R").unwrap();
635        assert_eq!(r.upper_bound, Some(1_999 * 16 + 15));
636        assert_eq!(r.point_count, Some(2_000 * 16));
637        assert_eq!(r.segments[0].upper_bound, Some(1_999 * 16 + 15));
638        assert_eq!(r.segments[1].upper_bound, Some(1_999 * 16 + 15));
639
640        let dm = catalog.entry("DM").unwrap();
641        assert_eq!(dm.device, "D");
642        assert_eq!(dm.category, KvDeviceRangeCategory::Word);
643        assert!(!dm.is_bit_device);
644        assert_eq!(dm.lower_bound, 0);
645        assert_eq!(dm.upper_bound, Some(65534));
646        assert_eq!(dm.point_count, Some(65535));
647        assert_eq!(dm.notation, KvDeviceRangeNotation::Decimal);
648        assert_eq!(dm.segments[0].device, "D");
649        assert_eq!(dm.segments[0].address_range, "D0-65534");
650        assert_eq!(catalog.entry("D").unwrap().device_type, "DM");
651
652        let fm = catalog.entry("FM").unwrap();
653        assert_eq!(fm.device, "F");
654        assert_eq!(fm.address_range.as_deref(), Some("F0-32767"));
655        assert_eq!(fm.segments[0].device, "F");
656        assert_eq!(fm.segments[0].address_range, "F0-32767");
657    }
658
659    #[test]
660    fn corrected_catalog_typos_are_published_consistently() {
661        let nano = device_range_catalog_for_model("KV-N24nn").unwrap();
662        assert_eq!(
663            nano.entry("CM").unwrap().address_range.as_deref(),
664            Some("CM0000-CM8999")
665        );
666
667        let xym = device_range_catalog_for_model("KV-3000/5000(XYM)").unwrap();
668        assert_eq!(
669            xym.entry("CR").unwrap().address_range.as_deref(),
670            Some("CR0000-CR3915")
671        );
672    }
673
674    #[test]
675    fn single_device_ranges_keep_their_device_prefixes() {
676        let nano = device_range_catalog_for_model("KV-N24nn").unwrap();
677        assert_eq!(
678            nano.entry("VM").unwrap().address_range.as_deref(),
679            Some("VM0-9499")
680        );
681        assert_eq!(
682            nano.entry("VB").unwrap().address_range.as_deref(),
683            Some("VB0-1FFF")
684        );
685        assert_eq!(
686            nano.entry("CTC").unwrap().address_range.as_deref(),
687            Some("CTC0-7")
688        );
689
690        let kv3000 = device_range_catalog_for_model("KV-3000/5000").unwrap();
691        assert_eq!(
692            kv3000.entry("AT").unwrap().address_range.as_deref(),
693            Some("AT0-7")
694        );
695        assert_eq!(
696            kv3000.entry("CTH").unwrap().address_range.as_deref(),
697            Some("CTH0-1")
698        );
699    }
700
701    #[test]
702    fn unsupported_entries_remain_present_but_marked_unsupported() {
703        let catalog = device_range_catalog_for_model("KV-N24nn").unwrap();
704        let em = catalog.entry("EM").unwrap();
705
706        assert!(!em.supported);
707        assert!(em.address_range.is_none());
708        assert!(em.segments.is_empty());
709    }
710
711    #[test]
712    fn normalize_model_key_removes_whitespace_and_uppercases() {
713        assert_eq!(normalize_model_key(" kv-x500 (xym) "), "KV-X500(XYM)");
714    }
715}