Skip to main content

m_bus_parser/
mbus_data.rs

1#[cfg(feature = "std")]
2use prettytable::{format, row, Table};
3use wireless_mbus_link_layer::WirelessFrame;
4
5use crate::user_data;
6use crate::MbusError;
7use wired_mbus_link_layer as frames;
8use wireless_mbus_link_layer;
9
10#[cfg_attr(
11    feature = "serde",
12    derive(serde::Serialize),
13    serde(bound(deserialize = "'de: 'a"))
14)]
15#[derive(Debug)]
16pub struct MbusData<'a, F> {
17    pub frame: F,
18    pub user_data: Option<user_data::UserDataBlock<'a>>,
19    pub data_records: Option<user_data::DataRecords<'a>>,
20}
21
22impl<'a> TryFrom<&'a [u8]> for MbusData<'a, frames::WiredFrame<'a>> {
23    type Error = MbusError;
24
25    fn try_from(data: &'a [u8]) -> Result<Self, Self::Error> {
26        let frame = frames::WiredFrame::try_from(data)?;
27        let mut user_data = None;
28        let mut data_records = None;
29        match &frame {
30            frames::WiredFrame::LongFrame { data, .. } => {
31                if let Ok(x) = user_data::UserDataBlock::try_from(*data) {
32                    match &x {
33                        user_data::UserDataBlock::VariableDataStructureWithLongTplHeader {
34                            variable_data_block,
35                            ..
36                        }
37                        | user_data::UserDataBlock::VariableDataStructureWithShortTplHeader {
38                            variable_data_block,
39                            ..
40                        }
41                        | user_data::UserDataBlock::VariableDataStructureWithoutTplHeader {
42                            variable_data_block,
43                            ..
44                        } => {
45                            data_records = Some((*variable_data_block).into());
46                        }
47                        _ => {}
48                    }
49                    user_data = Some(x);
50                }
51            }
52            frames::WiredFrame::SingleCharacter { .. } => (),
53            frames::WiredFrame::ShortFrame { .. } => (),
54            frames::WiredFrame::ControlFrame { .. } => (),
55            _ => (),
56        };
57
58        Ok(MbusData {
59            frame,
60            user_data,
61            data_records,
62        })
63    }
64}
65
66impl<'a> TryFrom<&'a [u8]> for MbusData<'a, WirelessFrame<'a>> {
67    type Error = MbusError;
68
69    fn try_from(data: &'a [u8]) -> Result<Self, Self::Error> {
70        let frame = wireless_mbus_link_layer::WirelessFrame::try_from(data)?;
71        let mut user_data = None;
72        let mut data_records = None;
73        // Extract application layer data from wireless frame
74        let wireless_mbus_link_layer::WirelessFrame { data, .. } = &frame;
75
76        if let Ok(user_data_block) = user_data::UserDataBlock::try_from(*data) {
77            match &user_data_block {
78                user_data::UserDataBlock::VariableDataStructureWithLongTplHeader {
79                    variable_data_block,
80                    ..
81                } => {
82                    data_records = Some((*variable_data_block).into());
83                }
84                user_data::UserDataBlock::VariableDataStructureWithShortTplHeader {
85                    variable_data_block,
86                    ..
87                } => {
88                    data_records = Some((*variable_data_block).into());
89                }
90                user_data::UserDataBlock::VariableDataStructureWithoutTplHeader {
91                    variable_data_block,
92                    ..
93                } => {
94                    data_records = Some((*variable_data_block).into());
95                }
96                _ => {}
97            }
98            user_data = Some(user_data_block);
99        }
100
101        Ok(MbusData {
102            frame,
103            user_data,
104            data_records,
105        })
106    }
107}
108
109#[cfg(feature = "std")]
110fn clean_and_convert(input: &str) -> Vec<u8> {
111    use core::str;
112    let input = input.trim();
113    let cleaned_data: String = input.replace("0x", "").replace([' ', ',', 'x'], "");
114
115    cleaned_data
116        .as_bytes()
117        .chunks(2)
118        .map(|chunk| {
119            let byte_str = str::from_utf8(chunk).unwrap_or_default();
120            u8::from_str_radix(byte_str, 16).unwrap_or_default()
121        })
122        .collect()
123}
124
125#[cfg(feature = "std")]
126#[must_use]
127pub fn serialize_mbus_data(data: &str, format: &str, key: Option<&[u8; 16]>) -> String {
128    match format {
129        "json" => parse_to_json(data, key),
130        "yaml" => parse_to_yaml(data, key),
131        "csv" => parse_to_csv(data, key),
132        "mermaid" => parse_to_mermaid(data, key),
133        "annotated" => parse_to_annotated(data),
134        "hexview" => parse_to_hexview(data, key),
135        "annotated-text" => parse_to_annotated_text(data),
136        _ => parse_to_table(data, key),
137    }
138}
139
140/// Normalized, human-readable interpretation of a parsed frame.
141///
142/// This is the single source of truth for the table and CSV renderers and is
143/// embedded as `summary` in the JSON and YAML outputs, so every output format
144/// exposes the same header information regardless of whether it comes from a
145/// long TPL header, a short TPL header or the wireless link layer.
146#[cfg(feature = "std")]
147#[derive(serde::Serialize)]
148struct FrameSummary {
149    frame_type: &'static str,
150    #[serde(skip_serializing_if = "Option::is_none")]
151    function: Option<String>,
152    #[serde(skip_serializing_if = "Option::is_none")]
153    address: Option<String>,
154    #[serde(skip_serializing_if = "Option::is_none")]
155    identification_number: Option<String>,
156    #[serde(skip_serializing_if = "Option::is_none")]
157    manufacturer: Option<ManufacturerSummary>,
158    #[serde(skip_serializing_if = "Option::is_none")]
159    access_number: Option<u8>,
160    #[serde(skip_serializing_if = "Option::is_none")]
161    status: Option<String>,
162    #[serde(skip_serializing_if = "Option::is_none")]
163    security_mode: Option<String>,
164    #[serde(skip_serializing_if = "Option::is_none")]
165    version: Option<u8>,
166    #[serde(skip_serializing_if = "Option::is_none")]
167    device_type: Option<String>,
168    encrypted: bool,
169    decrypted: bool,
170    #[serde(skip_serializing_if = "Option::is_none")]
171    encrypted_payload_hex: Option<String>,
172    records: Vec<RecordSummary>,
173}
174
175#[cfg(feature = "std")]
176#[derive(serde::Serialize)]
177struct ManufacturerSummary {
178    code: String,
179    #[serde(skip_serializing_if = "Option::is_none")]
180    name: Option<String>,
181    #[serde(skip_serializing_if = "Option::is_none")]
182    website: Option<String>,
183    #[serde(skip_serializing_if = "Option::is_none")]
184    description: Option<String>,
185}
186
187#[cfg(feature = "std")]
188#[derive(serde::Serialize)]
189struct RecordSummary {
190    /// Human-readable rendering (same string the table and CSV outputs use).
191    display: String,
192    #[serde(skip_serializing_if = "Option::is_none")]
193    value: Option<f64>,
194    #[serde(skip_serializing_if = "Option::is_none")]
195    exponent: Option<isize>,
196    #[serde(skip_serializing_if = "Option::is_none")]
197    unit: Option<String>,
198    #[serde(skip_serializing_if = "Option::is_none")]
199    quantity: Option<String>,
200    data_information: String,
201    header_hex: String,
202    data_hex: String,
203}
204
205#[cfg(feature = "std")]
206#[derive(serde::Serialize)]
207struct SummarySection<'a> {
208    summary: &'a FrameSummary,
209}
210
211#[cfg(feature = "std")]
212struct ParsedOutput {
213    summary: FrameSummary,
214    json: serde_json::Value,
215    yaml: String,
216}
217
218#[cfg(feature = "std")]
219impl FrameSummary {
220    fn new(frame_type: &'static str) -> Self {
221        Self {
222            frame_type,
223            function: None,
224            address: None,
225            identification_number: None,
226            manufacturer: None,
227            access_number: None,
228            status: None,
229            security_mode: None,
230            version: None,
231            device_type: None,
232            encrypted: false,
233            decrypted: false,
234            encrypted_payload_hex: None,
235            records: Vec::new(),
236        }
237    }
238}
239
240#[cfg(feature = "std")]
241fn manufacturer_summary(code: String) -> ManufacturerSummary {
242    let info = crate::manufacturers::lookup_manufacturer(&code);
243    ManufacturerSummary {
244        code,
245        name: info.as_ref().map(|i| i.name.to_string()),
246        website: info.as_ref().map(|i| i.website.to_string()),
247        description: info.as_ref().map(|i| i.description.to_string()),
248    }
249}
250
251#[cfg(feature = "std")]
252fn hex_string(data: &[u8]) -> String {
253    data.iter().map(|b| format!("{:02X}", b)).collect()
254}
255
256#[cfg(feature = "std")]
257fn user_data_is_encrypted(user_data: Option<&user_data::UserDataBlock>) -> bool {
258    use user_data::UserDataBlock;
259    match user_data {
260        Some(UserDataBlock::VariableDataStructureWithLongTplHeader {
261            long_tpl_header, ..
262        }) => long_tpl_header.is_encrypted(),
263        Some(UserDataBlock::VariableDataStructureWithShortTplHeader {
264            short_tpl_header, ..
265        }) => short_tpl_header.is_encrypted(),
266        _ => false,
267    }
268}
269
270#[cfg(feature = "std")]
271fn variable_data_block<'a>(user_data: Option<&user_data::UserDataBlock<'a>>) -> Option<&'a [u8]> {
272    use user_data::UserDataBlock;
273    match user_data {
274        Some(UserDataBlock::VariableDataStructureWithLongTplHeader {
275            variable_data_block,
276            ..
277        })
278        | Some(UserDataBlock::VariableDataStructureWithShortTplHeader {
279            variable_data_block,
280            ..
281        })
282        | Some(UserDataBlock::VariableDataStructureWithoutTplHeader {
283            variable_data_block,
284            ..
285        }) => Some(variable_data_block),
286        _ => None,
287    }
288}
289
290#[cfg(feature = "std")]
291fn record_summaries(records: &user_data::DataRecords) -> Vec<RecordSummary> {
292    use user_data::data_information::DataType;
293
294    records
295        .clone()
296        .flatten()
297        .map(|record| {
298            let value_information = record
299                .data_record_header
300                .processed_data_record_header
301                .value_information
302                .as_ref();
303            let value_information_display = match value_information {
304                Some(x) => format!("{}", x),
305                None => ")".to_string(),
306            };
307            let data_information = match record
308                .data_record_header
309                .processed_data_record_header
310                .data_information
311            {
312                Some(ref x) => format!("{}", x),
313                None => "None".to_string(),
314            };
315            let value = match record.data.value {
316                Some(DataType::Number(n)) | Some(DataType::LossyNumber(n)) => Some(n),
317                _ => None,
318            };
319            let unit = value_information
320                .map(|vi| vi.units.iter().map(ToString::to_string).collect::<String>())
321                .filter(|s| !s.is_empty());
322            let quantity = value_information
323                .map(|vi| {
324                    vi.labels
325                        .iter()
326                        .map(|label| format!("{:?}", label))
327                        .collect::<Vec<_>>()
328                        .join(", ")
329                })
330                .filter(|s| !s.is_empty());
331            RecordSummary {
332                display: format!("({}{}", record.data, value_information_display),
333                value,
334                exponent: value_information.map(|vi| vi.decimal_scale_exponent),
335                unit,
336                quantity,
337                data_information,
338                header_hex: record.data_record_header_hex(),
339                data_hex: record.data_hex(),
340            }
341        })
342        .collect()
343}
344
345#[cfg(feature = "std")]
346fn apply_user_data_summary(
347    summary: &mut FrameSummary,
348    user_data: Option<&user_data::UserDataBlock>,
349) {
350    use user_data::UserDataBlock;
351    match user_data {
352        Some(UserDataBlock::VariableDataStructureWithLongTplHeader {
353            long_tpl_header, ..
354        }) => {
355            summary.identification_number = Some(long_tpl_header.identification_number.to_string());
356            summary.manufacturer = Some(manufacturer_summary(
357                long_tpl_header
358                    .manufacturer
359                    .as_ref()
360                    .map_or_else(|e| format!("Error: {}", e), |m| m.to_string()),
361            ));
362            summary.access_number = Some(long_tpl_header.short_tpl_header.access_number);
363            summary.status = Some(long_tpl_header.short_tpl_header.status.to_string());
364            summary.security_mode = Some(
365                long_tpl_header
366                    .short_tpl_header
367                    .configuration_field
368                    .security_mode()
369                    .to_string(),
370            );
371            summary.version = Some(long_tpl_header.version);
372            summary.device_type = Some(long_tpl_header.device_type.to_string());
373        }
374        Some(UserDataBlock::VariableDataStructureWithShortTplHeader {
375            short_tpl_header, ..
376        }) => {
377            summary.access_number = Some(short_tpl_header.access_number);
378            summary.status = Some(short_tpl_header.status.to_string());
379            summary.security_mode = Some(
380                short_tpl_header
381                    .configuration_field
382                    .security_mode()
383                    .to_string(),
384            );
385        }
386        Some(UserDataBlock::FixedDataStructure {
387            identification_number,
388            access_number,
389            status,
390            ..
391        }) => {
392            summary.identification_number = Some(identification_number.to_string());
393            summary.access_number = Some(*access_number);
394            summary.status = Some(status.to_string());
395        }
396        _ => {}
397    }
398}
399
400#[cfg(feature = "std")]
401fn finalize_summary(
402    summary: &mut FrameSummary,
403    data_records: Option<&user_data::DataRecords>,
404    user_data: Option<&user_data::UserDataBlock>,
405    encrypted: bool,
406    decrypted: bool,
407) {
408    summary.encrypted = encrypted;
409    summary.decrypted = decrypted;
410    if encrypted && !decrypted {
411        summary.encrypted_payload_hex = variable_data_block(user_data).map(hex_string);
412    } else if let Some(records) = data_records {
413        summary.records = record_summaries(records);
414    }
415}
416
417#[cfg(feature = "std")]
418fn summarize_wired(
419    parsed: &MbusData<frames::WiredFrame>,
420    encrypted: bool,
421    decrypted: bool,
422) -> FrameSummary {
423    let mut summary = match &parsed.frame {
424        frames::WiredFrame::LongFrame { .. } => FrameSummary::new("LongFrame"),
425        frames::WiredFrame::ShortFrame { .. } => FrameSummary::new("ShortFrame"),
426        frames::WiredFrame::ControlFrame { .. } => FrameSummary::new("ControlFrame"),
427        frames::WiredFrame::SingleCharacter { .. } => FrameSummary::new("SingleCharacter"),
428        _ => FrameSummary::new("Unknown"),
429    };
430    match &parsed.frame {
431        frames::WiredFrame::LongFrame {
432            function, address, ..
433        }
434        | frames::WiredFrame::ControlFrame {
435            function, address, ..
436        }
437        | frames::WiredFrame::ShortFrame { function, address } => {
438            summary.function = Some(function.to_string());
439            summary.address = Some(address.to_string());
440        }
441        _ => {}
442    }
443    apply_user_data_summary(&mut summary, parsed.user_data.as_ref());
444    finalize_summary(
445        &mut summary,
446        parsed.data_records.as_ref(),
447        parsed.user_data.as_ref(),
448        encrypted,
449        decrypted,
450    );
451    summary
452}
453
454#[cfg(feature = "std")]
455fn summarize_wireless(
456    parsed: &MbusData<WirelessFrame>,
457    encrypted: bool,
458    decrypted: bool,
459) -> FrameSummary {
460    let manufacturer_id = &parsed.frame.manufacturer_id;
461    let mut summary = FrameSummary::new("Wireless");
462    summary.function = Some(parsed.frame.function.to_string());
463    summary.identification_number = Some(manufacturer_id.identification_number.to_string());
464    summary.manufacturer = Some(manufacturer_summary(
465        manufacturer_id.manufacturer_code.to_string(),
466    ));
467    summary.version = Some(manufacturer_id.version);
468    summary.device_type = Some(manufacturer_id.device_type.to_string());
469    // A long TPL header overrides the link-layer addressing where present.
470    apply_user_data_summary(&mut summary, parsed.user_data.as_ref());
471    finalize_summary(
472        &mut summary,
473        parsed.data_records.as_ref(),
474        parsed.user_data.as_ref(),
475        encrypted,
476        decrypted,
477    );
478    summary
479}
480
481#[cfg(feature = "std")]
482fn build_output<T: serde::Serialize>(parsed: &T, summary: FrameSummary) -> ParsedOutput {
483    let mut json = serde_json::to_value(parsed).unwrap_or_default();
484    if let serde_json::Value::Object(ref mut map) = json {
485        map.insert(
486            "summary".to_string(),
487            serde_json::to_value(&summary).unwrap_or_default(),
488        );
489    }
490
491    let mut yaml = serde_yaml::to_string(parsed).unwrap_or_default();
492    yaml.push_str(
493        &serde_yaml::to_string(&SummarySection { summary: &summary }).unwrap_or_default(),
494    );
495
496    ParsedOutput {
497        summary,
498        json,
499        yaml,
500    }
501}
502
503/// Parses a frame (wired first, wireless as fallback), applies decryption when
504/// a key is provided, and produces the shared output model every text format
505/// renders from.
506#[cfg(feature = "std")]
507#[cfg_attr(not(feature = "decryption"), allow(unused_mut))]
508fn parse_frame_output(input: &str, key: Option<&[u8; 16]>) -> Option<ParsedOutput> {
509    let data = clean_and_convert(input);
510    #[cfg(feature = "decryption")]
511    let mut decrypted_buffer = [0u8; 256];
512    #[cfg(not(feature = "decryption"))]
513    let _ = key;
514
515    // Try wired first
516    if let Ok(mut parsed) = MbusData::<frames::WiredFrame>::try_from(data.as_slice()) {
517        let encrypted = user_data_is_encrypted(parsed.user_data.as_ref());
518        let mut decrypted = false;
519        #[cfg(feature = "decryption")]
520        if encrypted {
521            if let (Some(key_bytes), Some(user_data)) = (key, parsed.user_data.as_ref()) {
522                if let Some(len) = decrypt_variable_data_with_key(
523                    user_data,
524                    None,
525                    key_bytes,
526                    &mut decrypted_buffer,
527                ) {
528                    if let (
529                        Some(decrypted_data),
530                        Some(user_data::UserDataBlock::VariableDataStructureWithLongTplHeader {
531                            long_tpl_header,
532                            ..
533                        }),
534                    ) = (decrypted_buffer.get(..len), &parsed.user_data)
535                    {
536                        parsed.data_records = Some(user_data::DataRecords::new(
537                            decrypted_data,
538                            Some(long_tpl_header),
539                        ));
540                        decrypted = true;
541                    }
542                }
543            }
544        }
545        let summary = summarize_wired(&parsed, encrypted, decrypted);
546        return Some(build_output(&parsed, summary));
547    }
548
549    // If wired fails, try wireless - strip Format A CRCs if present
550    let mut crc_buf = [0u8; 512];
551    let wireless_data =
552        wireless_mbus_link_layer::strip_format_a_crcs(&data, &mut crc_buf).unwrap_or(&data);
553    if let Ok(mut parsed) =
554        MbusData::<wireless_mbus_link_layer::WirelessFrame>::try_from(wireless_data)
555    {
556        let encrypted = user_data_is_encrypted(parsed.user_data.as_ref());
557        let mut decrypted = false;
558        #[cfg(feature = "decryption")]
559        if encrypted {
560            if let (Some(key_bytes), Some(user_data)) = (key, parsed.user_data.as_ref()) {
561                if let Some(len) = decrypt_variable_data_with_key(
562                    user_data,
563                    Some(&parsed.frame.manufacturer_id),
564                    key_bytes,
565                    &mut decrypted_buffer,
566                ) {
567                    if let Some(decrypted_data) = decrypted_buffer.get(..len) {
568                        let long_header = match &parsed.user_data {
569                            Some(
570                                user_data::UserDataBlock::VariableDataStructureWithLongTplHeader {
571                                    long_tpl_header,
572                                    ..
573                                },
574                            ) => Some(long_tpl_header),
575                            _ => None,
576                        };
577                        parsed.data_records =
578                            Some(user_data::DataRecords::new(decrypted_data, long_header));
579                        decrypted = true;
580                    }
581                }
582            }
583        }
584        let summary = summarize_wireless(&parsed, encrypted, decrypted);
585        return Some(build_output(&parsed, summary));
586    }
587
588    None
589}
590
591#[cfg(feature = "std")]
592#[must_use]
593pub fn parse_to_json(input: &str, key: Option<&[u8; 16]>) -> String {
594    match parse_frame_output(input, key) {
595        Some(output) => serde_json::to_string_pretty(&output.json).unwrap_or_default(),
596        None => "{}".to_string(),
597    }
598}
599
600#[cfg(feature = "std")]
601#[must_use]
602fn parse_to_yaml(input: &str, key: Option<&[u8; 16]>) -> String {
603    match parse_frame_output(input, key) {
604        Some(output) => output.yaml,
605        None => "---\nerror: Could not parse data\n".to_string(),
606    }
607}
608
609#[cfg(feature = "std")]
610#[must_use]
611fn parse_to_table(input: &str, key: Option<&[u8; 16]>) -> String {
612    match parse_frame_output(input, key) {
613        Some(output) => render_table(&output.summary, key.is_some()),
614        None => "Error: Could not parse data as wired or wireless M-Bus".to_string(),
615    }
616}
617
618#[cfg(feature = "std")]
619fn render_table(summary: &FrameSummary, key_provided: bool) -> String {
620    let mut out = String::new();
621    let title = match summary.frame_type {
622        "LongFrame" => "Long Frame",
623        "ShortFrame" => "Short Frame",
624        "ControlFrame" => "Control Frame",
625        "SingleCharacter" => "Single Character Frame",
626        "Wireless" => "Wireless Frame",
627        other => other,
628    };
629    out.push_str(title);
630    out.push('\n');
631
632    // Wired frames carry link-layer function and address in their own table.
633    if let (Some(function), Some(address)) = (&summary.function, &summary.address) {
634        let mut table = Table::new();
635        table.set_format(*format::consts::FORMAT_BOX_CHARS);
636        table.set_titles(row!["Function", "Address"]);
637        table.add_row(row![function, address]);
638        out.push_str(&table.to_string());
639    }
640
641    let mut info_table = Table::new();
642    info_table.set_format(*format::consts::FORMAT_BOX_CHARS);
643    info_table.set_titles(row!["Field", "Value"]);
644    if summary.address.is_none() {
645        if let Some(function) = &summary.function {
646            info_table.add_row(row!["Function", function]);
647        }
648    }
649    if let Some(identification_number) = &summary.identification_number {
650        info_table.add_row(row!["Identification Number", identification_number]);
651    }
652    if let Some(manufacturer) = &summary.manufacturer {
653        info_table.add_row(row!["Manufacturer", manufacturer.code]);
654        if let Some(name) = &manufacturer.name {
655            info_table.add_row(row!["Manufacturer Name", name]);
656        }
657        if let Some(website) = &manufacturer.website {
658            info_table.add_row(row!["Website", website]);
659        }
660        if let Some(description) = &manufacturer.description {
661            info_table.add_row(row!["Description", description]);
662        }
663    }
664    if let Some(access_number) = summary.access_number {
665        info_table.add_row(row!["Access Number", access_number]);
666    }
667    if let Some(status) = &summary.status {
668        info_table.add_row(row!["Status", status]);
669    }
670    if let Some(security_mode) = &summary.security_mode {
671        info_table.add_row(row!["Security Mode", security_mode]);
672    }
673    if let Some(version) = summary.version {
674        info_table.add_row(row!["Version", version]);
675    }
676    if let Some(device_type) = &summary.device_type {
677        info_table.add_row(row!["Device Type", device_type]);
678    }
679    if !info_table.is_empty() {
680        out.push_str(&info_table.to_string());
681    }
682
683    if summary.encrypted && !summary.decrypted {
684        if key_provided {
685            out.push_str("Decryption failed\n");
686        }
687        if let Some(payload_hex) = &summary.encrypted_payload_hex {
688            out.push_str("Encrypted Payload : ");
689            out.push_str(payload_hex);
690            out.push('\n');
691        }
692        return out;
693    }
694
695    if summary.decrypted {
696        out.push_str("Decrypted successfully\n");
697    }
698
699    if matches!(summary.frame_type, "LongFrame" | "Wireless") || !summary.records.is_empty() {
700        let mut value_table = Table::new();
701        value_table.set_format(*format::consts::FORMAT_BOX_CHARS);
702        value_table.set_titles(row!["Value", "Data Information", "Header Hex", "Data Hex"]);
703        for record in &summary.records {
704            value_table.add_row(row![
705                record.display,
706                record.data_information,
707                record.header_hex,
708                record.data_hex
709            ]);
710        }
711        out.push_str(&value_table.to_string());
712    }
713
714    out
715}
716
717#[cfg(feature = "std")]
718#[must_use]
719pub fn parse_to_csv(input: &str, key: Option<&[u8; 16]>) -> String {
720    use prettytable::csv;
721
722    let mut writer = csv::Writer::from_writer(vec![]);
723
724    let Some(output) = parse_frame_output(input, key) else {
725        writer
726            .write_record(["Error"])
727            .map_err(|_| ())
728            .unwrap_or_default();
729        writer
730            .write_record(["Error parsing data as wired or wireless M-Bus"])
731            .map_err(|_| ())
732            .unwrap_or_default();
733        let csv_data = writer.into_inner().unwrap_or_default();
734        return String::from_utf8(csv_data)
735            .unwrap_or_else(|_| "Error converting CSV data to string".to_string());
736    };
737
738    let summary = &output.summary;
739
740    let mut headers = vec![
741        "FrameType".to_string(),
742        "Function".to_string(),
743        "Address".to_string(),
744        "Identification Number".to_string(),
745        "Manufacturer".to_string(),
746        "Manufacturer Name".to_string(),
747        "Access Number".to_string(),
748        "Status".to_string(),
749        "Security Mode".to_string(),
750        "Version".to_string(),
751        "Device Type".to_string(),
752    ];
753    for i in 1..=summary.records.len() {
754        headers.push(format!("DataPoint{}_Value", i));
755        headers.push(format!("DataPoint{}_Info", i));
756    }
757    writer
758        .write_record(&headers)
759        .map_err(|_| ())
760        .unwrap_or_default();
761
762    let mut row = vec![
763        summary.frame_type.to_string(),
764        summary.function.clone().unwrap_or_default(),
765        summary.address.clone().unwrap_or_default(),
766        summary.identification_number.clone().unwrap_or_default(),
767        summary
768            .manufacturer
769            .as_ref()
770            .map(|m| m.code.clone())
771            .unwrap_or_default(),
772        summary
773            .manufacturer
774            .as_ref()
775            .and_then(|m| m.name.clone())
776            .unwrap_or_default(),
777        summary
778            .access_number
779            .map(|n| n.to_string())
780            .unwrap_or_default(),
781        summary.status.clone().unwrap_or_default(),
782        summary.security_mode.clone().unwrap_or_default(),
783        summary.version.map(|v| v.to_string()).unwrap_or_default(),
784        summary.device_type.clone().unwrap_or_default(),
785    ];
786    for record in &summary.records {
787        row.push(record.display.clone());
788        row.push(record.data_information.clone());
789    }
790    writer
791        .write_record(&row)
792        .map_err(|_| ())
793        .unwrap_or_default();
794
795    let csv_data = writer.into_inner().unwrap_or_default();
796    String::from_utf8(csv_data)
797        .unwrap_or_else(|_| "Error converting CSV data to string".to_string())
798}
799
800#[cfg(feature = "std")]
801#[must_use]
802pub fn parse_to_mermaid(input: &str, _key: Option<&[u8; 16]>) -> String {
803    use user_data::UserDataBlock;
804
805    const MAX_PER_ROW: usize = 4;
806    // Colors for data record nodes (fill, text), cycling through the palette
807    const RECORD_COLORS: &[(&str, &str)] = &[
808        ("#1565c0", "#fff"),
809        ("#2e7d32", "#fff"),
810        ("#e65100", "#fff"),
811        ("#6a1b9a", "#fff"),
812        ("#c62828", "#fff"),
813        ("#00695c", "#fff"),
814        ("#f9a825", "#000"),
815        ("#4527a0", "#fff"),
816    ];
817
818    let data = clean_and_convert(input);
819
820    // Try wired first
821    if let Ok(parsed_data) = MbusData::<frames::WiredFrame>::try_from(data.as_slice()) {
822        let mut out = String::from("flowchart TD\n");
823        let mut styles = String::new();
824
825        match parsed_data.frame {
826            frames::WiredFrame::LongFrame {
827                function,
828                address,
829                data: _,
830            } => {
831                // Frame header subgraph
832                out.push_str("    subgraph FRAME_SG[\"Frame Header\"]\n");
833                out.push_str("");
834                out.push_str(&format!(
835                    "        FTYPE[\"Long Frame\"]\n        FUNC[\"Function: {}\"]\n        ADDR[\"Address: {}\"]\n",
836                    mermaid_escape(&format!("{}", function)),
837                    mermaid_escape(&format!("{}", address))
838                ));
839                let (chains, pads) =
840                    mermaid_centered_chains(&["FTYPE", "FUNC", "ADDR"], MAX_PER_ROW, "FP");
841                out.push_str(&chains);
842                out.push_str("    end\n");
843                styles.push_str(&pads);
844                styles.push_str("    style FRAME_SG fill:#2e86c1,color:#fff,stroke:#1a5276\n");
845                styles.push_str("    style FTYPE fill:#2980b9,color:#fff,stroke:#1a5276\n");
846                styles.push_str("    style FUNC fill:#2980b9,color:#fff,stroke:#1a5276\n");
847                styles.push_str("    style ADDR fill:#2980b9,color:#fff,stroke:#1a5276\n");
848
849                if let Some(UserDataBlock::VariableDataStructureWithLongTplHeader {
850                    long_tpl_header,
851                    ..
852                }) = &parsed_data.user_data
853                {
854                    let mfr = long_tpl_header
855                        .manufacturer
856                        .as_ref()
857                        .map_or_else(|e| format!("Error: {}", e), |m| format!("{}", m));
858
859                    let mfr_info = crate::manufacturers::lookup_manufacturer(&mfr);
860                    out.push_str("    subgraph DEV_SG[\"Device Info\"]\n");
861                    out.push_str("");
862                    out.push_str(&format!(
863                        "        DEV1[\"ID: {}\"]\n",
864                        mermaid_escape(&format!("{}", long_tpl_header.identification_number))
865                    ));
866                    out.push_str(&format!(
867                        "        DEV2[\"Manufacturer: {}\"]\n",
868                        mermaid_escape(&mfr)
869                    ));
870                    out.push_str(&format!(
871                        "        DEV3[\"Version: {}\"]\n",
872                        long_tpl_header.version
873                    ));
874                    out.push_str(&format!(
875                        "        DEV4[\"Device Type: {}\"]\n",
876                        mermaid_escape(&format!("{:?}", long_tpl_header.device_type))
877                    ));
878                    out.push_str(&format!(
879                        "        DEV5[\"Access Number: {}\"]\n",
880                        long_tpl_header.short_tpl_header.access_number
881                    ));
882                    out.push_str(&format!(
883                        "        DEV6[\"Status: {}\"]\n",
884                        mermaid_escape(&format!("{}", long_tpl_header.short_tpl_header.status))
885                    ));
886                    let mut dev_node_count = 6usize;
887                    if let Some(ref info) = mfr_info {
888                        dev_node_count += 1;
889                        out.push_str(&format!(
890                            "        DEV{}[\"Name: {}\"]\n",
891                            dev_node_count,
892                            mermaid_escape(info.name)
893                        ));
894                        dev_node_count += 1;
895                        out.push_str(&format!(
896                            "        DEV{}[\"Website: {}\"]\n",
897                            dev_node_count,
898                            mermaid_escape(info.website)
899                        ));
900                        dev_node_count += 1;
901                        out.push_str(&format!(
902                            "        DEV{}[\"{}\"]\n",
903                            dev_node_count,
904                            mermaid_escape(info.description)
905                        ));
906                    }
907                    let dev_ids: Vec<String> =
908                        (1..=dev_node_count).map(|i| format!("DEV{}", i)).collect();
909                    let dev_id_refs: Vec<&str> = dev_ids.iter().map(|s| s.as_str()).collect();
910                    let (chains, pads) = mermaid_centered_chains(&dev_id_refs, MAX_PER_ROW, "DP");
911                    out.push_str(&chains);
912                    out.push_str("    end\n");
913                    styles.push_str(&pads);
914                    styles.push_str("    style DEV_SG fill:#1e8449,color:#fff,stroke:#145a32\n");
915                    for i in 1..=dev_node_count {
916                        styles.push_str(&format!(
917                            "    style DEV{} fill:#27ae60,color:#fff,stroke:#145a32\n",
918                            i
919                        ));
920                    }
921
922                    out.push_str("    FRAME_SG --> DEV_SG\n");
923                }
924
925                if let Some(data_records) = parsed_data.data_records {
926                    out.push_str("    subgraph REC_SG[\"Data Records\"]\n");
927                    out.push_str("");
928                    let records: Vec<_> = data_records.flatten().collect();
929                    for (i, record) in records.iter().enumerate() {
930                        let value_information = match record
931                            .data_record_header
932                            .processed_data_record_header
933                            .value_information
934                        {
935                            Some(ref x) => format!("{}", x),
936                            None => String::new(),
937                        };
938                        let label = format!("({}{}", record.data, value_information);
939                        out.push_str(&format!("        R{}[\"{}\"]\n", i, mermaid_escape(&label)));
940                        let (fill, text) = RECORD_COLORS
941                            .get(i % RECORD_COLORS.len())
942                            .copied()
943                            .unwrap_or(("#888", "#fff"));
944                        styles.push_str(&format!(
945                            "    style R{} fill:{},color:{},stroke:#333\n",
946                            i, fill, text
947                        ));
948                    }
949                    let ids: Vec<String> = (0..records.len()).map(|i| format!("R{}", i)).collect();
950                    let id_refs: Vec<&str> = ids.iter().map(|s| s.as_str()).collect();
951                    let (chains, pads) = mermaid_centered_chains(&id_refs, MAX_PER_ROW, "RP");
952                    out.push_str(&chains);
953                    out.push_str("    end\n");
954                    styles.push_str("    style REC_SG fill:#6c3483,color:#fff,stroke:#4a235a\n");
955                    styles.push_str(&pads);
956                    out.push_str("    DEV_SG --> REC_SG\n");
957                }
958            }
959            frames::WiredFrame::ShortFrame { function, address } => {
960                out.push_str("    subgraph FRAME_SG[\"Short Frame\"]\n");
961                out.push_str(&format!(
962                    "        FUNC[\"Function: {}\"]\n        ADDR[\"Address: {}\"]\n",
963                    mermaid_escape(&format!("{}", function)),
964                    mermaid_escape(&format!("{}", address))
965                ));
966                out.push_str("    end\n");
967                styles.push_str("    style FRAME_SG fill:#2e86c1,color:#fff,stroke:#1a5276\n");
968            }
969            frames::WiredFrame::SingleCharacter { character } => {
970                out.push_str(&format!(
971                    "    FRAME[\"Single Character: 0x{:02X}\"]\n",
972                    character
973                ));
974                styles.push_str("    style FRAME fill:#2e86c1,color:#fff,stroke:#1a5276\n");
975            }
976            frames::WiredFrame::ControlFrame {
977                function, address, ..
978            } => {
979                out.push_str("    subgraph FRAME_SG[\"Control Frame\"]\n");
980                out.push_str(&format!(
981                    "        FUNC[\"Function: {}\"]\n        ADDR[\"Address: {}\"]\n",
982                    mermaid_escape(&format!("{}", function)),
983                    mermaid_escape(&format!("{}", address))
984                ));
985                out.push_str("    end\n");
986                styles.push_str("    style FRAME_SG fill:#2e86c1,color:#fff,stroke:#1a5276\n");
987            }
988            _ => {
989                out.push_str("    FRAME[\"Unknown Frame\"]\n");
990            }
991        }
992        out.push_str(&styles);
993        return out;
994    }
995
996    // Try wireless
997    let mut crc_buf = [0u8; 512];
998    let wireless_data =
999        wireless_mbus_link_layer::strip_format_a_crcs(&data, &mut crc_buf).unwrap_or(&data);
1000    if let Ok(parsed_data) =
1001        MbusData::<wireless_mbus_link_layer::WirelessFrame>::try_from(wireless_data)
1002    {
1003        let wireless_mbus_link_layer::WirelessFrame {
1004            function,
1005            manufacturer_id,
1006            data: _,
1007        } = &parsed_data.frame;
1008
1009        let mut out = String::from("flowchart TD\n");
1010        let mut styles = String::new();
1011
1012        let wmfr_str = format!("{}", manufacturer_id.manufacturer_code);
1013        let wmfr_info = crate::manufacturers::lookup_manufacturer(&wmfr_str);
1014        out.push_str("    subgraph FRAME_SG[\"Wireless Frame\"]\n");
1015        out.push_str(&format!("        FUNC[\"Function: {:?}\"]\n", function));
1016        out.push_str(&format!(
1017            "        MFR[\"Manufacturer: {}\"]\n",
1018            mermaid_escape(&wmfr_str)
1019        ));
1020        out.push_str(&format!(
1021            "        ID[\"ID: {:?}\"]\n",
1022            manufacturer_id.identification_number
1023        ));
1024        out.push_str(&format!(
1025            "        DEVT[\"Device Type: {:?}\"]\n",
1026            manufacturer_id.device_type
1027        ));
1028        out.push_str(&format!(
1029            "        VER[\"Version: {:?}\"]\n",
1030            manufacturer_id.version
1031        ));
1032        let mut wframe_nodes: Vec<&str> = vec!["FUNC", "MFR", "ID", "DEVT", "VER"];
1033        if let Some(ref info) = wmfr_info {
1034            out.push_str(&format!(
1035                "        MFRNAME[\"Name: {}\"]\n",
1036                mermaid_escape(info.name)
1037            ));
1038            out.push_str(&format!(
1039                "        MFRWEB[\"Website: {}\"]\n",
1040                mermaid_escape(info.website)
1041            ));
1042            out.push_str(&format!(
1043                "        MFRDESC[\"{}\"]\n",
1044                mermaid_escape(info.description)
1045            ));
1046            wframe_nodes.extend_from_slice(&["MFRNAME", "MFRWEB", "MFRDESC"]);
1047        }
1048        out.push_str("    end\n");
1049        styles.push_str("    style FRAME_SG fill:#2e86c1,color:#fff,stroke:#1a5276\n");
1050        for node in &wframe_nodes {
1051            styles.push_str(&format!(
1052                "    style {} fill:#2980b9,color:#fff,stroke:#1a5276\n",
1053                node
1054            ));
1055        }
1056
1057        if let Some(data_records) = parsed_data.data_records {
1058            out.push_str("    subgraph REC_SG[\"Data Records\"]\n");
1059            let records: Vec<_> = data_records.flatten().collect();
1060            for (i, record) in records.iter().enumerate() {
1061                let value_information = match record
1062                    .data_record_header
1063                    .processed_data_record_header
1064                    .value_information
1065                {
1066                    Some(ref x) => format!("{}", x),
1067                    None => String::new(),
1068                };
1069                let label = format!("({}{}", record.data, value_information);
1070                out.push_str(&format!("        R{}[\"{}\"]\n", i, mermaid_escape(&label)));
1071                let (fill, text) = RECORD_COLORS
1072                    .get(i % RECORD_COLORS.len())
1073                    .copied()
1074                    .unwrap_or(("#888", "#fff"));
1075                styles.push_str(&format!(
1076                    "    style R{} fill:{},color:{},stroke:#333\n",
1077                    i, fill, text
1078                ));
1079            }
1080            out.push_str("    end\n");
1081            styles.push_str("    style REC_SG fill:#6c3483,color:#fff,stroke:#4a235a\n");
1082            out.push_str("    FRAME_SG --> REC_SG\n");
1083        }
1084
1085        out.push_str(&styles);
1086        return out;
1087    }
1088
1089    "flowchart TD\n    ERR[\"Error: Could not parse data\"]\n".to_string()
1090}
1091
1092/// Returns (body, styles) where body contains padding node declarations + chain lines,
1093/// and styles contains the invisible styles for padding nodes.
1094/// Pads each incomplete row symmetrically so nodes appear centred.
1095#[cfg(feature = "std")]
1096fn mermaid_centered_chains(ids: &[&str], max_per_row: usize, pad_prefix: &str) -> (String, String) {
1097    let mut body = String::new();
1098    let mut styles = String::new();
1099    let mut pad_idx = 0usize;
1100    for chunk in ids.chunks(max_per_row) {
1101        let row: Vec<String> = if chunk.len() == max_per_row {
1102            chunk.iter().map(|s| s.to_string()).collect()
1103        } else {
1104            let padding = max_per_row - chunk.len();
1105            let left = padding / 2;
1106            let right = padding - left;
1107            let mut row: Vec<String> = Vec::new();
1108            for _ in 0..left {
1109                let id = format!("{}P{}", pad_prefix, pad_idx);
1110                body.push_str(&format!("        {}[\" \"]\n", id));
1111                styles.push_str(&format!(
1112                    "    style {} fill:none,stroke:none,color:none\n",
1113                    id
1114                ));
1115                row.push(id);
1116                pad_idx += 1;
1117            }
1118            row.extend(chunk.iter().map(|s| s.to_string()));
1119            for _ in 0..right {
1120                let id = format!("{}P{}", pad_prefix, pad_idx);
1121                body.push_str(&format!("        {}[\" \"]\n", id));
1122                styles.push_str(&format!(
1123                    "    style {} fill:none,stroke:none,color:none\n",
1124                    id
1125                ));
1126                row.push(id);
1127                pad_idx += 1;
1128            }
1129            row
1130        };
1131        // Emit individual pairs instead of a chain to maximise mermaid compatibility
1132        for pair in row.windows(2) {
1133            if let (Some(a), Some(b)) = (pair.first(), pair.get(1)) {
1134                body.push_str(&format!("        {}~~~{}\n", a, b));
1135            }
1136        }
1137    }
1138    (body, styles)
1139}
1140
1141#[cfg(feature = "std")]
1142#[must_use]
1143fn parse_to_annotated(input: &str) -> String {
1144    let data = clean_and_convert(input);
1145    annotated_segments_to_json(&data)
1146}
1147
1148#[cfg(feature = "std")]
1149#[must_use]
1150fn parse_to_hexview(input: &str, key: Option<&[u8; 16]>) -> String {
1151    let data = clean_and_convert(input);
1152
1153    #[cfg(feature = "decryption")]
1154    if let Some(key_bytes) = key {
1155        if let Some(display_data) = decrypted_hexview_data(&data, key_bytes) {
1156            return match crate::annotate::annotate_frame(&display_data) {
1157                Ok(mut segments) => {
1158                    replace_encrypted_payload_segments(&mut segments, &display_data);
1159                    serde_json::to_string_pretty(&serde_json::json!({
1160                        "bytes": display_data,
1161                        "segments": segments,
1162                        "decrypted": true,
1163                    }))
1164                    .unwrap_or_default()
1165                }
1166                Err(e) => format!("{{\"error\": \"{}\"}}", e),
1167            };
1168        }
1169    }
1170
1171    #[cfg(not(feature = "decryption"))]
1172    let _ = key;
1173
1174    annotated_segments_to_json(&data)
1175}
1176
1177#[cfg(feature = "std")]
1178#[must_use]
1179fn annotated_segments_to_json(data: &[u8]) -> String {
1180    match crate::annotate::annotate_frame(data) {
1181        Ok(segments) => serde_json::to_string_pretty(&segments).unwrap_or_default(),
1182        Err(e) => format!("{{\"error\": \"{}\"}}", e),
1183    }
1184}
1185
1186#[cfg(all(feature = "std", feature = "decryption"))]
1187fn decrypted_hexview_data(data: &[u8], key: &[u8; 16]) -> Option<Vec<u8>> {
1188    decrypted_wired_hexview_data(data, key).or_else(|| decrypted_wireless_hexview_data(data, key))
1189}
1190
1191#[cfg(all(feature = "std", feature = "decryption"))]
1192fn replace_encrypted_payload_segments(
1193    segments: &mut Vec<crate::annotate::ByteSegment>,
1194    display_data: &[u8],
1195) {
1196    let mut rewritten = Vec::with_capacity(segments.len());
1197
1198    for segment in segments.drain(..) {
1199        if segment.kind == crate::annotate::SegmentKind::EncryptedPayload {
1200            let before_len = rewritten.len();
1201            if let Some(data) = display_data.get(segment.start..segment.end) {
1202                crate::annotate::annotate_data_records(&mut rewritten, segment.start, data);
1203            }
1204            if rewritten.len() == before_len {
1205                rewritten.push(segment);
1206            }
1207        } else {
1208            rewritten.push(segment);
1209        }
1210    }
1211
1212    *segments = rewritten;
1213}
1214
1215#[cfg(all(feature = "std", feature = "decryption"))]
1216fn decrypted_wired_hexview_data(data: &[u8], key: &[u8; 16]) -> Option<Vec<u8>> {
1217    let parsed_data = MbusData::<frames::WiredFrame>::try_from(data).ok()?;
1218    let user_data = parsed_data.user_data.as_ref()?;
1219    let variable_data_len = user_data.variable_data_len();
1220    if variable_data_len == 0 {
1221        return None;
1222    }
1223
1224    let mut decrypted_buffer = [0u8; 256];
1225    let decrypted_len =
1226        decrypt_variable_data_with_key(user_data, None, key, &mut decrypted_buffer)?;
1227
1228    let user_data_len = match parsed_data.frame {
1229        frames::WiredFrame::LongFrame {
1230            data: user_data_slice,
1231            ..
1232        }
1233        | frames::WiredFrame::ControlFrame {
1234            data: user_data_slice,
1235            ..
1236        } => user_data_slice.len(),
1237        _ => return None,
1238    };
1239
1240    let payload_start = 6 + user_data_len.checked_sub(variable_data_len)?;
1241    let payload_end = payload_start + variable_data_len;
1242    let mut display_data = data.to_vec();
1243    let decrypted_data = decrypted_buffer.get(..decrypted_len)?;
1244    display_data.splice(payload_start..payload_end, decrypted_data.iter().copied());
1245
1246    let length = display_data.len().checked_sub(6)?;
1247    if length > u8::MAX as usize || display_data.len() < 6 {
1248        return None;
1249    }
1250    *display_data.get_mut(1)? = length as u8;
1251    *display_data.get_mut(2)? = length as u8;
1252
1253    let checksum_index = display_data.len().checked_sub(2)?;
1254    let checksum = display_data
1255        .get(4..checksum_index)?
1256        .iter()
1257        .fold(0u8, |acc, byte| acc.wrapping_add(*byte));
1258    *display_data.get_mut(checksum_index)? = checksum;
1259
1260    Some(display_data)
1261}
1262
1263#[cfg(all(feature = "std", feature = "decryption"))]
1264fn decrypted_wireless_hexview_data(data: &[u8], key: &[u8; 16]) -> Option<Vec<u8>> {
1265    let mut crc_buf = [0u8; 512];
1266    let wireless_data =
1267        wireless_mbus_link_layer::strip_format_a_crcs(data, &mut crc_buf).unwrap_or(data);
1268    let parsed_data =
1269        MbusData::<wireless_mbus_link_layer::WirelessFrame>::try_from(wireless_data).ok()?;
1270    let user_data = parsed_data.user_data.as_ref()?;
1271    let variable_data_len = user_data.variable_data_len();
1272    if variable_data_len == 0 {
1273        return None;
1274    }
1275
1276    let mut decrypted_buffer = [0u8; 256];
1277    let manufacturer_id = &parsed_data.frame.manufacturer_id;
1278    let decrypted_len = decrypt_variable_data_with_key(
1279        user_data,
1280        Some(manufacturer_id),
1281        key,
1282        &mut decrypted_buffer,
1283    )?;
1284
1285    let app_data_len = parsed_data.frame.data.len();
1286    let app_start = wireless_data.len().checked_sub(app_data_len)?;
1287    let payload_start = app_start + app_data_len.checked_sub(variable_data_len)?;
1288    let payload_end = payload_start + variable_data_len;
1289
1290    let mut display_data = wireless_data.to_vec();
1291    let decrypted_data = decrypted_buffer.get(..decrypted_len)?;
1292    display_data.splice(payload_start..payload_end, decrypted_data.iter().copied());
1293    if let Some(length) = display_data.len().checked_sub(1) {
1294        if length <= u8::MAX as usize {
1295            if let Some(length_byte) = display_data.get_mut(0) {
1296                *length_byte = length as u8;
1297            }
1298        }
1299    }
1300
1301    Some(display_data)
1302}
1303
1304#[cfg(all(feature = "std", feature = "decryption"))]
1305fn decrypt_variable_data_with_key(
1306    user_data: &user_data::UserDataBlock<'_>,
1307    wireless_manufacturer_id: Option<&wireless_mbus_link_layer::ManufacturerId>,
1308    key: &[u8; 16],
1309    output: &mut [u8],
1310) -> Option<usize> {
1311    use user_data::UserDataBlock;
1312
1313    let mut provider = crate::decryption::StaticKeyProvider::<1>::new();
1314    match user_data {
1315        UserDataBlock::VariableDataStructureWithLongTplHeader {
1316            long_tpl_header, ..
1317        } => {
1318            if !long_tpl_header.is_encrypted() {
1319                return None;
1320            }
1321            let manufacturer = long_tpl_header.manufacturer.as_ref().ok()?;
1322            provider
1323                .add_key(
1324                    manufacturer.to_id(),
1325                    long_tpl_header.identification_number.number,
1326                    *key,
1327                )
1328                .ok()?;
1329            user_data.decrypt_variable_data(&provider, output).ok()
1330        }
1331        UserDataBlock::VariableDataStructureWithShortTplHeader {
1332            short_tpl_header, ..
1333        } => {
1334            if !short_tpl_header.is_encrypted() {
1335                return None;
1336            }
1337            let manufacturer_id = wireless_manufacturer_id?;
1338            provider
1339                .add_key(
1340                    manufacturer_id.manufacturer_code.to_id(),
1341                    manufacturer_id.identification_number.number,
1342                    *key,
1343                )
1344                .ok()?;
1345            user_data
1346                .decrypt_variable_data_with_context(
1347                    &provider,
1348                    manufacturer_id.manufacturer_code,
1349                    manufacturer_id.identification_number.number,
1350                    manufacturer_id.version,
1351                    manufacturer_id.device_type,
1352                    output,
1353                )
1354                .ok()
1355        }
1356        _ => None,
1357    }
1358}
1359
1360#[cfg(feature = "std")]
1361#[must_use]
1362fn parse_to_annotated_text(input: &str) -> String {
1363    let data = clean_and_convert(input);
1364    match crate::annotate::annotate_and_render(&data) {
1365        Ok(text) => text,
1366        Err(e) => format!("Error: {}", e),
1367    }
1368}
1369
1370#[cfg(feature = "std")]
1371fn mermaid_escape(s: &str) -> String {
1372    s.replace('"', "#quot;")
1373        .replace('[', "#91;")
1374        .replace(']', "#93;")
1375}
1376
1377#[cfg(test)]
1378mod tests {
1379
1380    #[cfg(feature = "std")]
1381    #[test]
1382    fn test_csv_converter() {
1383        use super::parse_to_csv;
1384        let input = "68 3D 3D 68 08 01 72 00 51 20 02 82 4D 02 04 00 88 00 00 04 07 00 00 00 00 0C 15 03 00 00 00 0B 2E 00 00 00 0B 3B 00 00 00 0A 5A 88 12 0A 5E 16 05 0B 61 23 77 00 02 6C 8C 11 02 27 37 0D 0F 60 00 67 16";
1385        let csv_output: String = parse_to_csv(input, None);
1386        println!("{}", csv_output);
1387        let yaml_output: String = super::parse_to_yaml(input, None);
1388        println!("{}", yaml_output);
1389        let json_output: String = super::parse_to_json(input, None);
1390        println!("{}", json_output);
1391        let table_output: String = super::parse_to_table(input, None);
1392        println!("{}", table_output);
1393    }
1394
1395    #[cfg(feature = "std")]
1396    #[test]
1397    fn test_csv_expected_output() {
1398        use super::parse_to_csv;
1399        let input = "68 3D 3D 68 08 01 72 00 51 20 02 82 4D 02 04 00 88 00 00 04 07 00 00 00 00 0C 15 03 00 00 00 0B 2E 00 00 00 0B 3B 00 00 00 0A 5A 88 12 0A 5E 16 05 0B 61 23 77 00 02 6C 8C 11 02 27 37 0D 0F 60 00 67 16";
1400        let csv_output = parse_to_csv(input, None);
1401
1402        let expected = "FrameType,Function,Address,Identification Number,Manufacturer,Manufacturer Name,Access Number,Status,Security Mode,Version,Device Type,DataPoint1_Value,DataPoint1_Info,DataPoint2_Value,DataPoint2_Info,DataPoint3_Value,DataPoint3_Info,DataPoint4_Value,DataPoint4_Info,DataPoint5_Value,DataPoint5_Info,DataPoint6_Value,DataPoint6_Info,DataPoint7_Value,DataPoint7_Info,DataPoint8_Value,DataPoint8_Info,DataPoint9_Value,DataPoint9_Info,DataPoint10_Value,DataPoint10_Info\nLongFrame,\"RspUd (ACD: false, DFC: false)\",Primary (1),02205100,SLB,Schlumberger Industries,0,\"Permanent error, Manufacturer specific 3\",No encryption used,2,Heat Meter (Return),(0)e4[Wh](Energy),\"0,Inst,32-bit Integer\",(3)e-1[m³](Volume),\"0,Inst,BCD 8-digit\",(0)e3[W](Power),\"0,Inst,BCD 6-digit\",(0)e-3[m³h⁻¹](VolumeFlow),\"0,Inst,BCD 6-digit\",(1288)e-1[°C](FlowTemperature),\"0,Inst,BCD 4-digit\",(516)e-1[°C](ReturnTemperature),\"0,Inst,BCD 4-digit\",(7723)e-2[°K](TemperatureDifference),\"0,Inst,BCD 6-digit\",(12/Jan/12)(Date),\"0,Inst,Date Type G\",(3383)[day](OperatingTime),\"0,Inst,16-bit Integer\",\"(Manufacturer Specific: [96, 0])\",\"0,Inst,Special Functions (ManufacturerSpecific)\"\n";
1403
1404        assert_eq!(csv_output, expected);
1405    }
1406
1407    #[cfg(feature = "std")]
1408    #[test]
1409    fn test_wireless_csv_contains_header_info() {
1410        // Wireless frame with a short TPL header: the identification number,
1411        // manufacturer, version and device type come from the link layer and
1412        // must not be missing from the CSV output.
1413        let input = "2E44931578563412330333637A2A0020255923C95AAA26D1B2E7493BC2AD013EC4A6F6D3529B520EDFF0EA6DEFC955B29D6D69EBF3EC8A";
1414        let csv_output = super::parse_to_csv(input, None);
1415
1416        let mut lines = csv_output.lines();
1417        let header: Vec<&str> = lines.next().expect("header row").split(',').collect();
1418        let row = lines.next().expect("data row");
1419        // Parse the row respecting quoting via a simple check of key fields
1420        assert!(header.starts_with(&[
1421            "FrameType",
1422            "Function",
1423            "Address",
1424            "Identification Number",
1425            "Manufacturer",
1426            "Manufacturer Name",
1427            "Access Number",
1428            "Status",
1429            "Security Mode",
1430            "Version",
1431            "Device Type"
1432        ]));
1433        assert!(row.starts_with("Wireless,SndNk,,12345678,ELS,Elster Group,42,No Error(s),"));
1434        assert!(row.contains("AES-CBC-128; IV ≠ 0"));
1435        assert!(row.contains(",51,Gas Meter"));
1436    }
1437
1438    #[cfg(feature = "std")]
1439    #[test]
1440    fn test_wireless_encrypted_csv_without_key_has_no_garbage_records() {
1441        let input = "2E44931578563412330333637A2A0020255923C95AAA26D1B2E7493BC2AD013EC4A6F6D3529B520EDFF0EA6DEFC955B29D6D69EBF3EC8A";
1442        let csv_output = super::parse_to_csv(input, None);
1443        assert!(!csv_output.contains("DataPoint1_Value"));
1444        // Header info must still be present even though the payload is encrypted
1445        assert!(csv_output.contains("12345678"));
1446    }
1447
1448    #[cfg(all(feature = "std", feature = "decryption"))]
1449    #[test]
1450    fn test_wireless_csv_with_key_contains_records() {
1451        let input = "2E44931578563412330333637A2A0020255923C95AAA26D1B2E7493BC2AD013EC4A6F6D3529B520EDFF0EA6DEFC955B29D6D69EBF3EC8A";
1452        let key = [
1453            0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E,
1454            0x0F, 0x11,
1455        ];
1456        let csv_output = super::parse_to_csv(input, Some(&key));
1457        assert!(csv_output.contains("12345678"));
1458        assert!(csv_output.contains("(2850427)e-2[m³](Volume)"));
1459    }
1460
1461    #[cfg(feature = "std")]
1462    #[test]
1463    fn test_json_and_yaml_contain_summary() {
1464        let input = "2E44931578563412330333637A2A0020255923C95AAA26D1B2E7493BC2AD013EC4A6F6D3529B520EDFF0EA6DEFC955B29D6D69EBF3EC8A";
1465        let json_output = super::parse_to_json(input, None);
1466        let parsed: serde_json::Value =
1467            serde_json::from_str(&json_output).expect("json output should be valid");
1468        let summary = parsed.get("summary").expect("json should contain summary");
1469        assert_eq!(
1470            summary.get("frame_type").and_then(|v| v.as_str()),
1471            Some("Wireless")
1472        );
1473        assert_eq!(
1474            summary
1475                .get("identification_number")
1476                .and_then(|v| v.as_str()),
1477            Some("12345678")
1478        );
1479        assert_eq!(
1480            summary
1481                .get("manufacturer")
1482                .and_then(|m| m.get("code"))
1483                .and_then(|v| v.as_str()),
1484            Some("ELS")
1485        );
1486        assert_eq!(
1487            summary.get("security_mode").and_then(|v| v.as_str()),
1488            Some("AES-CBC-128; IV ≠ 0")
1489        );
1490        assert_eq!(
1491            summary.get("status").and_then(|v| v.as_str()),
1492            Some("No Error(s)")
1493        );
1494        assert_eq!(
1495            summary.get("encrypted").and_then(|v| v.as_bool()),
1496            Some(true)
1497        );
1498
1499        let yaml_output = super::parse_to_yaml(input, None);
1500        assert!(yaml_output.contains("summary:"));
1501        assert!(yaml_output.contains("frame_type: Wireless"));
1502        assert!(yaml_output.contains("security_mode: AES-CBC-128; IV ≠ 0"));
1503    }
1504
1505    #[cfg(feature = "std")]
1506    #[test]
1507    fn test_all_formats_agree_on_header_fields() {
1508        // The same header information must be present in every output format.
1509        let inputs = [
1510            // wired long frame
1511            "68 3D 3D 68 08 01 72 00 51 20 02 82 4D 02 04 00 88 00 00 04 07 00 00 00 00 0C 15 03 00 00 00 0B 2E 00 00 00 0B 3B 00 00 00 0A 5A 88 12 0A 5E 16 05 0B 61 23 77 00 02 6C 8C 11 02 27 37 0D 0F 60 00 67 16",
1512            // wireless with short TPL header
1513            "2E44931578563412330333637A2A0020255923C95AAA26D1B2E7493BC2AD013EC4A6F6D3529B520EDFF0EA6DEFC955B29D6D69EBF3EC8A",
1514            // wireless without TPL header (ELL)
1515            "1444AE0C7856341201078C2027780B134365877AC5",
1516        ];
1517        for input in inputs {
1518            let output = super::parse_frame_output(input, None).expect("frame should parse");
1519            let summary = &output.summary;
1520            let table = super::parse_to_table(input, None);
1521            let csv = super::parse_to_csv(input, None);
1522            let json = super::parse_to_json(input, None);
1523            let yaml = super::parse_to_yaml(input, None);
1524            for value in [
1525                summary.identification_number.clone(),
1526                summary.manufacturer.as_ref().map(|m| m.code.clone()),
1527                summary.status.clone(),
1528                summary.security_mode.clone(),
1529                summary.device_type.clone(),
1530            ]
1531            .into_iter()
1532            .flatten()
1533            {
1534                for (format, output) in [
1535                    ("table", &table),
1536                    ("csv", &csv),
1537                    ("json", &json),
1538                    ("yaml", &yaml),
1539                ] {
1540                    assert!(
1541                        output.contains(&value),
1542                        "{format} output is missing header value {value:?} for input {input}"
1543                    );
1544                }
1545            }
1546
1547            // The structured record fields in the JSON summary must agree with
1548            // the normalized summary the table and CSV render from.
1549            let parsed: serde_json::Value =
1550                serde_json::from_str(&json).expect("json output should be valid");
1551            let json_records = parsed
1552                .get("summary")
1553                .and_then(|s| s.get("records"))
1554                .and_then(|r| r.as_array())
1555                .expect("json summary should contain records");
1556            assert_eq!(json_records.len(), summary.records.len());
1557            for (json_record, record) in json_records.iter().zip(&summary.records) {
1558                assert_eq!(
1559                    json_record.get("display").and_then(|v| v.as_str()),
1560                    Some(record.display.as_str()),
1561                    "json record display mismatch for input {input}"
1562                );
1563                assert_eq!(
1564                    json_record.get("value").and_then(|v| v.as_f64()),
1565                    record.value,
1566                    "json record value mismatch for input {input}"
1567                );
1568                assert_eq!(
1569                    json_record
1570                        .get("exponent")
1571                        .and_then(|v| v.as_i64())
1572                        .map(|v| v as isize),
1573                    record.exponent,
1574                    "json record exponent mismatch for input {input}"
1575                );
1576                assert_eq!(
1577                    json_record.get("unit").and_then(|v| v.as_str()),
1578                    record.unit.as_deref(),
1579                    "json record unit mismatch for input {input}"
1580                );
1581                assert_eq!(
1582                    json_record.get("quantity").and_then(|v| v.as_str()),
1583                    record.quantity.as_deref(),
1584                    "json record quantity mismatch for input {input}"
1585                );
1586                // The display string the table/CSV formats use must embed the
1587                // same structured information.
1588                if let Some(unit) = &record.unit {
1589                    assert!(
1590                        record.display.contains(unit.as_str()),
1591                        "display {:?} should contain unit {unit:?}",
1592                        record.display
1593                    );
1594                }
1595                if let Some(exponent) = record.exponent.filter(|e| *e != 0) {
1596                    assert!(
1597                        record.display.contains(&format!("e{exponent}")),
1598                        "display {:?} should contain exponent {exponent}",
1599                        record.display
1600                    );
1601                }
1602            }
1603        }
1604    }
1605
1606    #[cfg(feature = "std")]
1607    #[test]
1608    fn test_json_records_contain_structured_values() {
1609        let input = "68 3D 3D 68 08 01 72 00 51 20 02 82 4D 02 04 00 88 00 00 04 07 00 00 00 00 0C 15 03 00 00 00 0B 2E 00 00 00 0B 3B 00 00 00 0A 5A 88 12 0A 5E 16 05 0B 61 23 77 00 02 6C 8C 11 02 27 37 0D 0F 60 00 67 16";
1610        let json_output = super::parse_to_json(input, None);
1611        let parsed: serde_json::Value =
1612            serde_json::from_str(&json_output).expect("json output should be valid");
1613        let records = parsed
1614            .get("summary")
1615            .and_then(|s| s.get("records"))
1616            .and_then(|r| r.as_array())
1617            .expect("summary should contain records");
1618
1619        // "(3)e-1[m³](Volume)" becomes structured fields.
1620        let volume = records
1621            .iter()
1622            .find(|r| r.get("quantity").and_then(|v| v.as_str()) == Some("Volume"))
1623            .expect("volume record");
1624        assert_eq!(volume.get("value").and_then(|v| v.as_f64()), Some(3.0));
1625        assert_eq!(volume.get("exponent").and_then(|v| v.as_i64()), Some(-1));
1626        assert_eq!(volume.get("unit").and_then(|v| v.as_str()), Some("m³"));
1627        assert_eq!(
1628            volume.get("display").and_then(|v| v.as_str()),
1629            Some("(3)e-1[m³](Volume)")
1630        );
1631
1632        // A date record has no numeric value but keeps its display string.
1633        let date = records
1634            .iter()
1635            .find(|r| r.get("quantity").and_then(|v| v.as_str()) == Some("Date"))
1636            .expect("date record");
1637        assert!(date.get("value").is_none());
1638        assert_eq!(
1639            date.get("display").and_then(|v| v.as_str()),
1640            Some("(12/Jan/12)(Date)")
1641        );
1642
1643        // A manufacturer-specific record has neither value information nor a
1644        // numeric value.
1645        let manufacturer_specific = records
1646            .iter()
1647            .find(|r| {
1648                r.get("data_information")
1649                    .and_then(|v| v.as_str())
1650                    .is_some_and(|s| s.contains("ManufacturerSpecific"))
1651            })
1652            .expect("manufacturer specific record");
1653        assert!(manufacturer_specific.get("value").is_none());
1654        assert!(manufacturer_specific.get("exponent").is_none());
1655        assert!(manufacturer_specific.get("unit").is_none());
1656        assert!(manufacturer_specific.get("quantity").is_none());
1657    }
1658
1659    #[cfg(feature = "std")]
1660    #[test]
1661    fn test_json_and_yaml_have_no_manufacturer_info() {
1662        let input = "68 3D 3D 68 08 01 72 00 51 20 02 82 4D 02 04 00 88 00 00 04 07 00 00 00 00 0C 15 03 00 00 00 0B 2E 00 00 00 0B 3B 00 00 00 0A 5A 88 12 0A 5E 16 05 0B 61 23 77 00 02 6C 8C 11 02 27 37 0D 0F 60 00 67 16";
1663        let json_output = super::parse_to_json(input, None);
1664        let parsed: serde_json::Value =
1665            serde_json::from_str(&json_output).expect("json output should be valid");
1666        assert!(parsed.get("manufacturer_info").is_none());
1667        // summary.manufacturer carries the same information instead.
1668        assert_eq!(
1669            parsed
1670                .get("summary")
1671                .and_then(|s| s.get("manufacturer"))
1672                .and_then(|m| m.get("name"))
1673                .and_then(|v| v.as_str()),
1674            Some("Schlumberger Industries")
1675        );
1676
1677        let yaml_output = super::parse_to_yaml(input, None);
1678        assert!(!yaml_output.contains("manufacturer_info:"));
1679        assert!(yaml_output.contains("name: Schlumberger Industries"));
1680    }
1681
1682    #[cfg(feature = "std")]
1683    #[test]
1684    fn test_byte_payloads_serialize_as_hex_strings() {
1685        // Wireless frame: frame.data must be a compact uppercase hex string.
1686        let wireless_input = "1444AE0C7856341201078C2027780B134365877AC5";
1687        let json_output = super::parse_to_json(wireless_input, None);
1688        let parsed: serde_json::Value =
1689            serde_json::from_str(&json_output).expect("json output should be valid");
1690        let frame_data = parsed
1691            .get("frame")
1692            .and_then(|f| f.get("data"))
1693            .expect("wireless frame should contain data");
1694        let hex = frame_data.as_str().expect("frame data should be a string");
1695        assert!(!hex.is_empty());
1696        assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
1697        assert_eq!(hex, hex.to_uppercase());
1698
1699        // Data records: raw_bytes must be hex strings as well.
1700        let wired_input = "68 3D 3D 68 08 01 72 00 51 20 02 82 4D 02 04 00 88 00 00 04 07 00 00 00 00 0C 15 03 00 00 00 0B 2E 00 00 00 0B 3B 00 00 00 0A 5A 88 12 0A 5E 16 05 0B 61 23 77 00 02 6C 8C 11 02 27 37 0D 0F 60 00 67 16";
1701        let json_output = super::parse_to_json(wired_input, None);
1702        let parsed: serde_json::Value =
1703            serde_json::from_str(&json_output).expect("json output should be valid");
1704        let records = parsed
1705            .get("data_records")
1706            .and_then(|r| r.as_array())
1707            .expect("data records");
1708        let first_raw_bytes = records
1709            .first()
1710            .and_then(|r| r.get("raw_bytes"))
1711            .and_then(|v| v.as_str())
1712            .expect("raw_bytes should be a hex string");
1713        assert_eq!(first_raw_bytes, "040700000000");
1714
1715        // Manufacturer-specific data is a hex string instead of a byte array.
1716        assert!(json_output.contains("\"ManufacturerSpecific\": \"6000\""));
1717    }
1718
1719    #[cfg(feature = "std")]
1720    #[test]
1721    fn test_yaml_expected_output() {
1722        use super::parse_to_yaml;
1723        let input = "68 3D 3D 68 08 01 72 00 51 20 02 82 4D 02 04 00 88 00 00 04 07 00 00 00 00 0C 15 03 00 00 00 0B 2E 00 00 00 0B 3B 00 00 00 0A 5A 88 12 0A 5E 16 05 0B 61 23 77 00 02 6C 8C 11 02 27 37 0D 0F 60 00 67 16";
1724        let yaml_output = parse_to_yaml(input, None);
1725
1726        // First line of YAML output to test against - we'll test just the beginning to avoid a massive string
1727        let expected_start = "frame: !LongFrame\n  function: !RspUd\n    acd: false\n    dfc: false\n  address: !Primary 1\nuser_data: !VariableDataStructureWithLongTplHeader\n";
1728
1729        assert!(yaml_output.starts_with(expected_start));
1730        // Additional checks for specific content in the YAML
1731        assert!(yaml_output.contains("device_type: HeatMeterReturn"));
1732        assert!(yaml_output.contains("identification_number:"));
1733        assert!(yaml_output.contains("status: PERMANENT_ERROR | MANUFACTURER_SPECIFIC_3"));
1734    }
1735
1736    #[cfg(feature = "std")]
1737    #[test]
1738    fn test_json_expected_output() {
1739        use super::parse_to_json;
1740        let input = "68 3D 3D 68 08 01 72 00 51 20 02 82 4D 02 04 00 88 00 00 04 07 00 00 00 00 0C 15 03 00 00 00 0B 2E 00 00 00 0B 3B 00 00 00 0A 5A 88 12 0A 5E 16 05 0B 61 23 77 00 02 6C 8C 11 02 27 37 0D 0F 60 00 67 16";
1741        let json_output = parse_to_json(input, None);
1742
1743        // Testing specific content in JSON
1744        assert!(json_output.contains("\"Ok\""));
1745        assert!(json_output.contains("\"LongFrame\""));
1746        assert!(json_output.contains("\"RspUd\""));
1747        assert!(json_output.contains("\"number\": 2205100"));
1748        assert!(json_output.contains("\"device_type\": \"HeatMeterReturn\""));
1749        assert!(json_output.contains("\"status\": \"PERMANENT_ERROR | MANUFACTURER_SPECIFIC_3\""));
1750
1751        // Verify JSON structure is valid
1752        let json_parsed = serde_json::from_str::<serde_json::Value>(&json_output);
1753        assert!(json_parsed.is_ok());
1754    }
1755
1756    #[cfg(feature = "std")]
1757    #[test]
1758    fn test_mermaid_expected_output() {
1759        use super::parse_to_mermaid;
1760        let input = "68 3D 3D 68 08 01 72 00 51 20 02 82 4D 02 04 00 88 00 00 04 07 00 00 00 00 0C 15 03 00 00 00 0B 2E 00 00 00 0B 3B 00 00 00 0A 5A 88 12 0A 5E 16 05 0B 61 23 77 00 02 6C 8C 11 02 27 37 0D 0F 60 00 67 16";
1761        let mermaid_output = parse_to_mermaid(input, None);
1762
1763        assert!(mermaid_output.starts_with("flowchart TD\n"));
1764        assert!(mermaid_output.contains("Long Frame"));
1765        assert!(mermaid_output.contains("Device Info"));
1766        assert!(mermaid_output.contains("Data Records"));
1767        assert!(mermaid_output.contains("02205100"));
1768        assert!(mermaid_output.contains("SLB"));
1769    }
1770
1771    #[cfg(feature = "std")]
1772    #[test]
1773    fn test_table_expected_output() {
1774        use super::parse_to_table;
1775        let input = "68 3D 3D 68 08 01 72 00 51 20 02 82 4D 02 04 00 88 00 00 04 07 00 00 00 00 0C 15 03 00 00 00 0B 2E 00 00 00 0B 3B 00 00 00 0A 5A 88 12 0A 5E 16 05 0B 61 23 77 00 02 6C 8C 11 02 27 37 0D 0F 60 00 67 16";
1776        let table_output = parse_to_table(input, None);
1777
1778        // First section of the table output
1779        assert!(table_output.starts_with("Long Frame"));
1780
1781        // Key content pieces to verify
1782        assert!(table_output.contains("RspUd (ACD: false, DFC: false)"));
1783        assert!(table_output.contains("Primary (1)"));
1784        assert!(table_output.contains("Identification Number"));
1785        assert!(table_output.contains("02205100"));
1786        assert!(table_output.contains("SLB"));
1787
1788        // Data point verifications
1789        assert!(table_output.contains("(0)e4[Wh]"));
1790        assert!(table_output.contains("(3)e-1[m³](Volume)"));
1791        assert!(table_output.contains("(1288)e-1[°C](FlowTemperature)"));
1792        assert!(table_output.contains("(12/Jan/12)(Date)"));
1793        assert!(table_output.contains("(3383)[day]"));
1794    }
1795
1796    #[cfg(all(feature = "std", feature = "decryption"))]
1797    #[test]
1798    fn decrypted_utf8_text_is_not_rendered_as_latin1() {
1799        let input = "2E44931578563412330333637A2A00202557FB8016CA78E1243700B52E981E1918233AFE5E826DD0D4AD7854C697E7C8EB";
1800        let key = [
1801            0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E,
1802            0x0F, 0x11,
1803        ];
1804
1805        for format in ["table", "json", "yaml", "csv"] {
1806            let output = super::serialize_mbus_data(input, format, Some(&key));
1807            assert!(
1808                output.contains("m³"),
1809                "{format} output should contain the decoded UTF-8 text: {output}"
1810            );
1811            assert!(
1812                !output.contains("m³"),
1813                "{format} output contains mojibake: {output}"
1814            );
1815        }
1816    }
1817
1818    #[cfg(feature = "std")]
1819    #[test]
1820    fn test_annotated_output() {
1821        let input = "68 4D 4D 68 08 01 72 01 00 00 00 96 15 01 00 18 00 00 00 0C 78 56 00 00 00 01 FD 1B 00 02 FC 03 48 52 25 74 44 0D 22 FC 03 48 52 25 74 F1 0C 12 FC 03 48 52 25 74 63 11 02 65 B4 09 22 65 86 09 12 65 B7 09 01 72 00 72 65 00 00 B2 01 65 00 00 1F B3 16";
1822        let output = super::serialize_mbus_data(input, "annotated", None);
1823
1824        // Should be valid JSON
1825        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap_or_else(|e| {
1826            panic!(
1827                "annotated output should be valid JSON: {}\nOutput: {}",
1828                e, output
1829            )
1830        });
1831
1832        // Should be an array
1833        assert!(parsed.is_array(), "annotated output should be a JSON array");
1834        let segments = parsed.as_array().expect("array");
1835
1836        // Should cover all 83 bytes
1837        assert!(!segments.is_empty());
1838
1839        // First segment should start at 0
1840        assert_eq!(segments[0].get("start").and_then(|v| v.as_u64()), Some(0));
1841
1842        // Last segment should end at 83
1843        let last = segments.last().expect("non-empty");
1844        assert_eq!(last.get("end").and_then(|v| v.as_u64()), Some(83));
1845
1846        // Check contiguity
1847        for window in segments.windows(2) {
1848            let end = window[0].get("end").and_then(|v| v.as_u64());
1849            let start = window[1].get("start").and_then(|v| v.as_u64());
1850            assert_eq!(end, start, "segments should be contiguous");
1851        }
1852    }
1853
1854    #[cfg(feature = "std")]
1855    #[test]
1856    fn test_hexview_output_for_ci_78_frame_is_annotated_json() {
1857        let input = "1444AE0C7856341201078C2027780B134365877AC5";
1858        let output = super::serialize_mbus_data(input, "hexview", None);
1859
1860        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap_or_else(|e| {
1861            panic!(
1862                "hexview output should be valid annotated JSON: {}\nOutput: {}",
1863                e, output
1864            )
1865        });
1866        let segments = parsed
1867            .as_array()
1868            .expect("hexview output should be a JSON array");
1869
1870        assert!(segments.iter().any(|seg| {
1871            seg.get("kind").and_then(|v| v.as_str()) == Some("CiField")
1872                && seg
1873                    .get("detail")
1874                    .and_then(|v| v.as_str())
1875                    .is_some_and(|detail| detail.contains("0x78"))
1876        }));
1877        assert!(segments.iter().any(|seg| {
1878            seg.get("kind").and_then(|v| v.as_str()) == Some("DataPayload")
1879                && seg.get("detail").and_then(|v| v.as_str()) == Some("876543")
1880        }));
1881        assert!(!segments.iter().any(|seg| {
1882            seg.get("kind").and_then(|v| v.as_str()) == Some("Unknown")
1883                || seg
1884                    .get("detail")
1885                    .and_then(|v| v.as_str())
1886                    .is_some_and(|detail| detail.contains("Unparseable data record bytes"))
1887        }));
1888    }
1889
1890    #[cfg(all(feature = "std", feature = "decryption"))]
1891    #[test]
1892    fn test_hexview_with_key_displays_decrypted_wireless_payload() {
1893        let input = "2E44931578563412330333637A2A0020255923C95AAA26D1B2E7493BC2AD013EC4A6F6D3529B520EDFF0EA6DEFC955B29D6D69EBF3EC8A";
1894        let key = [
1895            0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E,
1896            0x0F, 0x11,
1897        ];
1898        let output = super::serialize_mbus_data(input, "hexview", Some(&key));
1899
1900        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap_or_else(|e| {
1901            panic!(
1902                "decrypted hexview output should be valid JSON: {}\nOutput: {}",
1903                e, output
1904            )
1905        });
1906        assert_eq!(
1907            parsed.get("decrypted").and_then(|v| v.as_bool()),
1908            Some(true)
1909        );
1910
1911        let bytes = parsed
1912            .get("bytes")
1913            .and_then(|v| v.as_array())
1914            .expect("decrypted hexview should include display bytes");
1915        let display_hex = bytes
1916            .iter()
1917            .map(|byte| format!("{:02X}", byte.as_u64().unwrap_or_default()))
1918            .collect::<String>();
1919        assert!(
1920            display_hex.contains("0C1427048502046D32371F1502FD170000"),
1921            "display bytes should contain decrypted record bytes: {display_hex}"
1922        );
1923
1924        let segments = parsed
1925            .get("segments")
1926            .and_then(|v| v.as_array())
1927            .expect("decrypted hexview should include annotation segments");
1928        assert!(segments.iter().any(|seg| {
1929            seg.get("kind").and_then(|v| v.as_str()) == Some("DataPayload")
1930                && seg
1931                    .get("detail")
1932                    .and_then(|v| v.as_str())
1933                    .is_some_and(|detail| detail.contains("2850427"))
1934        }));
1935        assert!(
1936            !segments
1937                .iter()
1938                .any(|seg| seg.get("kind").and_then(|v| v.as_str()) == Some("EncryptedPayload")),
1939            "decrypted hexview should annotate decrypted records, not ciphertext payloads"
1940        );
1941    }
1942}