rs-modbus 2.0.0

A pure Rust implementation of MODBUS protocol.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
use crate::vars::{FunctionCode, EXCEPTION_OFFSET, MEI_READ_DEVICE_ID};

const CRC_TABLE: [u16; 256] = [
    0x0000, 0xc0c1, 0xc181, 0x0140, 0xc301, 0x03c0, 0x0280, 0xc241, 0xc601, 0x06c0, 0x0780, 0xc741,
    0x0500, 0xc5c1, 0xc481, 0x0440, 0xcc01, 0x0cc0, 0x0d80, 0xcd41, 0x0f00, 0xcfc1, 0xce81, 0x0e40,
    0x0a00, 0xcac1, 0xcb81, 0x0b40, 0xc901, 0x09c0, 0x0880, 0xc841, 0xd801, 0x18c0, 0x1980, 0xd941,
    0x1b00, 0xdbc1, 0xda81, 0x1a40, 0x1e00, 0xdec1, 0xdf81, 0x1f40, 0xdd01, 0x1dc0, 0x1c80, 0xdc41,
    0x1400, 0xd4c1, 0xd581, 0x1540, 0xd701, 0x17c0, 0x1680, 0xd641, 0xd201, 0x12c0, 0x1380, 0xd341,
    0x1100, 0xd1c1, 0xd081, 0x1040, 0xf001, 0x30c0, 0x3180, 0xf141, 0x3300, 0xf3c1, 0xf281, 0x3240,
    0x3600, 0xf6c1, 0xf781, 0x3740, 0xf501, 0x35c0, 0x3480, 0xf441, 0x3c00, 0xfcc1, 0xfd81, 0x3d40,
    0xff01, 0x3fc0, 0x3e80, 0xfe41, 0xfa01, 0x3ac0, 0x3b80, 0xfb41, 0x3900, 0xf9c1, 0xf881, 0x3840,
    0x2800, 0xe8c1, 0xe981, 0x2940, 0xeb01, 0x2bc0, 0x2a80, 0xea41, 0xee01, 0x2ec0, 0x2f80, 0xef41,
    0x2d00, 0xedc1, 0xec81, 0x2c40, 0xe401, 0x24c0, 0x2580, 0xe541, 0x2700, 0xe7c1, 0xe681, 0x2640,
    0x2200, 0xe2c1, 0xe381, 0x2340, 0xe101, 0x21c0, 0x2080, 0xe041, 0xa001, 0x60c0, 0x6180, 0xa141,
    0x6300, 0xa3c1, 0xa281, 0x6240, 0x6600, 0xa6c1, 0xa781, 0x6740, 0xa501, 0x65c0, 0x6480, 0xa441,
    0x6c00, 0xacc1, 0xad81, 0x6d40, 0xaf01, 0x6fc0, 0x6e80, 0xae41, 0xaa01, 0x6ac0, 0x6b80, 0xab41,
    0x6900, 0xa9c1, 0xa881, 0x6840, 0x7800, 0xb8c1, 0xb981, 0x7940, 0xbb01, 0x7bc0, 0x7a80, 0xba41,
    0xbe01, 0x7ec0, 0x7f80, 0xbf41, 0x7d00, 0xbdc1, 0xbc81, 0x7c40, 0xb401, 0x74c0, 0x7580, 0xb541,
    0x7700, 0xb7c1, 0xb681, 0x7640, 0x7200, 0xb2c1, 0xb381, 0x7340, 0xb101, 0x71c0, 0x7080, 0xb041,
    0x5000, 0x90c1, 0x9181, 0x5140, 0x9301, 0x53c0, 0x5280, 0x9241, 0x9601, 0x56c0, 0x5780, 0x9741,
    0x5500, 0x95c1, 0x9481, 0x5440, 0x9c01, 0x5cc0, 0x5d80, 0x9d41, 0x5f00, 0x9fc1, 0x9e81, 0x5e40,
    0x5a00, 0x9ac1, 0x9b81, 0x5b40, 0x9901, 0x59c0, 0x5880, 0x9841, 0x8801, 0x48c0, 0x4980, 0x8941,
    0x4b00, 0x8bc1, 0x8a81, 0x4a40, 0x4e00, 0x8ec1, 0x8f81, 0x4f40, 0x8d01, 0x4dc0, 0x4c80, 0x8c41,
    0x4400, 0x84c1, 0x8581, 0x4540, 0x8701, 0x47c0, 0x4680, 0x8641, 0x8201, 0x42c0, 0x4380, 0x8341,
    0x4100, 0x81c1, 0x8081, 0x4040,
];

pub fn crc(data: &[u8]) -> u16 {
    crc_with_seed(data, 0xffff)
}

/// Update an in-progress CRC by feeding `data`. Pass `0xffff` for a fresh
/// computation, or the previous result to extend a running CRC by additional
/// bytes (mirrors njs-modbus `crc(data, seed)`).
pub fn crc_with_seed(data: &[u8], seed: u16) -> u16 {
    let mut crc = seed;
    for &byte in data {
        crc = CRC_TABLE[((crc ^ byte as u16) & 0xff) as usize] ^ (crc >> 8);
    }
    crc
}

pub fn lrc(data: &[u8]) -> u8 {
    let sum: u8 = data.iter().copied().fold(0u8, |a, b| a.wrapping_add(b));
    (0u8).wrapping_sub(sum)
}

fn in_range(n: u16, (min, max): (u16, u16)) -> bool {
    n >= min && n <= max
}

pub fn check_range(value: &[u16], range: &[(u16, u16)]) -> bool {
    if range.is_empty() {
        return true;
    }
    for &(min, max) in range {
        if min <= max && value.iter().all(|&v| in_range(v, (min, max))) {
            return true;
        }
    }
    false
}

pub fn get_three_point_five_t(baud_rate: u32, approximation: u32) -> f64 {
    bits_to_ms(baud_rate, approximation as f64)
}

/// Convert a number of bits to milliseconds at the given baud rate.
///
/// Mirrors njs-modbus `bitsToMs`. Used to derive Modbus RTU timing intervals
/// from bit counts — e.g. 38.5 bits = 3.5 character times at 11 bits/char (t3.5
/// inter-frame silence), or 16.5 bits = 1.5 character times (t1.5
/// inter-character timeout), per Modbus V1.02 §2.5.1.1.
pub fn bits_to_ms(baud_rate: u32, bits: f64) -> f64 {
    (bits * 1000.0) / baud_rate as f64
}

/// Returns true when `n` is in the unsigned-byte range `[0, 255]`.
///
/// Mirrors njs-modbus `isUint8`. Used for byte-level Modbus payload
/// validation (FC17 server-ID elements, FC43 object values, custom-FC
/// payloads).
pub fn is_uint8(n: i32) -> bool {
    (0..=255).contains(&n)
}

pub fn pack_coils(coils: &[bool], length: u16) -> Vec<u8> {
    let byte_count = ((length + 7) / 8) as usize;
    let mut data = Vec::with_capacity(1 + byte_count);
    data.push(byte_count as u8);
    for chunk in coils.chunks(8) {
        let mut byte = 0u8;
        for (bit_idx, &v) in chunk.iter().enumerate() {
            if v {
                byte |= 1 << bit_idx;
            }
        }
        data.push(byte);
    }
    data
}

pub fn parse_coils(data: &[u8], length: u16) -> Vec<bool> {
    let mut result = Vec::with_capacity(length as usize);
    for byte_idx in 0..((length + 7) / 8) as usize {
        let byte = data[1 + byte_idx];
        for bit_idx in 0..8 {
            let i = byte_idx * 8 + bit_idx;
            if i >= length as usize {
                break;
            }
            result.push((byte >> bit_idx) & 1 == 1);
        }
    }
    result
}

pub fn pack_registers(registers: &[u16], length: u16) -> Vec<u8> {
    let byte_count = (length * 2) as usize;
    let mut data = Vec::with_capacity(1 + byte_count);
    data.push(byte_count as u8);
    for reg in registers {
        data.extend_from_slice(&reg.to_be_bytes());
    }
    data
}

pub fn parse_registers(data: &[u8], length: u16) -> Vec<u16> {
    let mut result = Vec::with_capacity(length as usize);
    for i in 0..length {
        let idx = 1 + (i as usize) * 2;
        result.push(u16::from_be_bytes([data[idx], data[idx + 1]]));
    }
    result
}

/// Predictor result mirroring njs-modbus `PredictResult` discriminated union.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PredictResult {
    /// Function code is known and total RTU frame length (PDU + 2-byte CRC) is determined.
    Length(usize),
    /// Function code is known, but more bytes are required to decide
    /// (typically waiting on the byteCount byte or a variable-length tail).
    NeedMore,
    /// Function code is not in the standard tables — caller must defer to a
    /// registered `CustomFunctionCode` or treat this as a framing error.
    Unknown,
}

/// True when `fc` has a built-in framing predictor (standard FC, exception, or
/// FC 0x2B response).
pub fn is_standard_fc(fc: u8, is_response: bool) -> bool {
    if is_response && (fc & EXCEPTION_OFFSET) != 0 {
        return true;
    }
    if is_response {
        matches!(
            fc,
            0x05 | 0x06 | 0x0f | 0x10 | 0x16 | 0x01 | 0x02 | 0x03 | 0x04 | 0x11 | 0x17 | 0x2b
        )
    } else {
        matches!(fc, 0x01..=0x06 | 0x11 | 0x16 | 0x2b | 0x0f | 0x10 | 0x17)
    }
}

/// Predict the total RTU frame length (PDU + 2-byte CRC) given the leading bytes.
///
/// Mirrors njs-modbus `predictRtuFrameLength` after the `PredictResult`
/// refactor. Returns:
/// - `Length(n)` — fc is known and length is determined.
/// - `NeedMore` — fc is known but more bytes are required.
/// - `Unknown` — fc is not in the standard tables; caller must consult any
///   registered `CustomFunctionCode` or treat as a framing error.
pub fn predict_rtu_frame_length(buffer: &[u8], is_response: bool) -> PredictResult {
    if buffer.len() < 2 {
        return PredictResult::NeedMore;
    }
    let fc = buffer[1];

    if is_response && (fc & EXCEPTION_OFFSET) != 0 {
        return PredictResult::Length(5);
    }

    let fixed = if is_response {
        match fc {
            0x05 | 0x06 | 0x0f | 0x10 => Some(8usize),
            0x16 => Some(10),
            _ => None,
        }
    } else {
        match fc {
            0x01..=0x06 => Some(8usize),
            0x11 => Some(4),
            0x16 => Some(10),
            0x2b => Some(7),
            _ => None,
        }
    };
    if let Some(n) = fixed {
        return PredictResult::Length(n);
    }

    let byte_count = if is_response {
        match fc {
            0x01 | 0x02 | 0x03 | 0x04 | 0x11 | 0x17 => Some((2usize, 5usize)),
            _ => None,
        }
    } else {
        match fc {
            0x0f | 0x10 => Some((6usize, 9usize)),
            0x17 => Some((10, 13)),
            _ => None,
        }
    };
    if let Some((offset, extra)) = byte_count {
        if buffer.len() <= offset {
            return PredictResult::NeedMore;
        }
        return PredictResult::Length(extra + buffer[offset] as usize);
    }

    if is_response && fc == FunctionCode::ReadDeviceIdentification.as_u8() {
        return predict_fc43_14_response(buffer);
    }

    PredictResult::Unknown
}

/// Walk the variable-length FC 0x2B / MEI 0x0E (Read Device Identification)
/// response structure per Modbus V1.1b3 §6.21.
///
/// Layout (after unit and fc):
///   mei(1) rdic(1) conformity(1) more(1) nextObjId(1) numObjs(1)
///   [objId(1) objLen(1) objData(objLen)] × numObjs
///   CRC(2)
fn predict_fc43_14_response(buffer: &[u8]) -> PredictResult {
    if buffer.len() < 8 {
        return PredictResult::NeedMore;
    }
    if buffer[2] != MEI_READ_DEVICE_ID {
        return PredictResult::Unknown;
    }
    let num_objs = buffer[7] as usize;
    let mut offset = 8usize;
    for _ in 0..num_objs {
        if buffer.len() < offset + 2 {
            return PredictResult::NeedMore;
        }
        let obj_len = buffer[offset + 1] as usize;
        offset += 2 + obj_len;
    }
    PredictResult::Length(offset + 2)
}

/// Generate a cross-process-unique connection id with the given prefix.
///
/// Format: `{prefix}-{uuid-v4}`. Uses RFC 4122 UUID v4 (128-bit random)
/// so IDs are unique even when multiple processes start simultaneously.
/// Mirrors njs-modbus `crypto.randomUUID()`.
pub fn gen_connection_id(prefix: &str) -> String {
    format!("{prefix}-{}", uuid::Uuid::new_v4())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_crc_known_values() {
        // Test with empty data
        assert_eq!(crc(&[]), 0xffff);
        // Test with single byte: TABLE[0xFE] ^ 0x00FF = 0x8081 ^ 0x00FF = 0x807E
        assert_eq!(crc(&[0x01]), 0x807e);
        // Test self-consistency: CRC of [data + crc(data)] should match
        let data = [0x01, 0x03, 0x00, 0x00, 0x00, 0x0a];
        let result = crc(&data);
        let frame_crc = result.to_le_bytes();
        let mut full_frame = data.to_vec();
        full_frame.extend_from_slice(&frame_crc);
        assert_eq!(crc(&full_frame[..full_frame.len() - 2]), result);
    }

    #[test]
    fn test_crc_with_seed_chains_equivalently() {
        // Splitting `crc(a ++ b)` into `crc_with_seed(b, crc(a))` must yield
        // the identical result — this is the invariant the sliding-window
        // running CRC in rtu.rs relies on.
        let data = [0x01u8, 0x03, 0x00, 0x00, 0x00, 0x0a, 0x77, 0xff];
        let full = crc(&data);
        for split in 0..=data.len() {
            let (left, right) = data.split_at(split);
            let chained = crc_with_seed(right, crc(left));
            assert_eq!(chained, full, "split={split}");
        }
        // crc() must equal crc_with_seed(.., 0xffff).
        assert_eq!(crc(&data), crc_with_seed(&data, 0xffff));
    }

    #[test]
    fn test_lrc_known_values() {
        // Empty data
        assert_eq!(lrc(&[]), 0x00);
        // Single byte: ~0x01 + 1 = 0xFF
        assert_eq!(lrc(&[0x01]), 0xff);
        // Two bytes: (0x01 + 0x03) = 0x04, ~0x04 + 1 = 0xFC
        assert_eq!(lrc(&[0x01, 0x03]), 0xfc);
    }

    #[test]
    fn test_check_range_single() {
        assert!(check_range(&[5], &[(0, 10)]));
        assert!(!check_range(&[15], &[(0, 10)]));
        assert!(check_range(&[5, 8], &[(0, 10)]));
        assert!(!check_range(&[5, 15], &[(0, 10)]));
    }

    #[test]
    fn test_check_range_multiple() {
        // Multiple ranges - any match is ok
        assert!(check_range(&[5], &[(0, 3), (4, 10)]));
        assert!(!check_range(&[5], &[(0, 3), (6, 10)]));
    }

    #[test]
    fn test_check_range_empty() {
        // Empty range means no restriction
        assert!(check_range(&[999], &[]));
    }

    #[test]
    fn test_check_range_invalid() {
        // min > max is invalid, skip
        assert!(!check_range(&[5], &[(10, 0)]));
    }

    #[test]
    fn test_three_point_five_t() {
        assert_eq!(get_three_point_five_t(9600, 48), 5.0);
        assert_eq!(get_three_point_five_t(19200, 48), 2.5);
    }

    #[test]
    fn test_parse_coils() {
        // 3 coils: true, false, true
        let data = vec![0x01, 0b00000101];
        let coils = parse_coils(&data, 3);
        assert_eq!(coils, vec![true, false, true]);

        // 8 coils: all true
        let data = vec![0x01, 0xFF];
        let coils = parse_coils(&data, 8);
        assert_eq!(coils, vec![true; 8]);

        // 10 coils
        let data = vec![0x02, 0b00000101, 0b00000011];
        let coils = parse_coils(&data, 10);
        assert_eq!(
            coils,
            vec![true, false, true, false, false, false, false, false, true, true]
        );
    }

    #[test]
    fn test_pack_coils() {
        let coils = vec![
            true, false, true, false, false, false, false, false, true, true,
        ];
        let packed = pack_coils(&coils, 10);
        assert_eq!(packed, vec![0x02, 0b00000101, 0b00000011]);
    }

    #[test]
    fn test_pack_and_parse_coils_roundtrip() {
        let coils = vec![true, false, true, true, false, true, false, true];
        let packed = pack_coils(&coils, 8);
        let parsed = parse_coils(&packed, 8);
        assert_eq!(coils, parsed);
    }

    #[test]
    fn test_parse_registers() {
        let data = vec![0x04, 0x00, 0x01, 0xAB, 0xCD];
        let regs = parse_registers(&data, 2);
        assert_eq!(regs, vec![0x0001, 0xABCD]);
    }

    #[test]
    fn test_pack_registers() {
        let regs = vec![0x0001u16, 0xABCD];
        let packed = pack_registers(&regs, 2);
        assert_eq!(packed, vec![0x04, 0x00, 0x01, 0xAB, 0xCD]);
    }

    #[test]
    fn test_pack_and_parse_registers_roundtrip() {
        let regs = vec![0x1234u16, 0x5678, 0x9ABC];
        let packed = pack_registers(&regs, 3);
        let parsed = parse_registers(&packed, 3);
        assert_eq!(regs, parsed);
    }

    // ===== predict_rtu_frame_length =====

    #[test]
    fn test_predict_buffer_too_short_need_more() {
        assert_eq!(
            predict_rtu_frame_length(&[], false),
            PredictResult::NeedMore
        );
        assert_eq!(
            predict_rtu_frame_length(&[0x01], false),
            PredictResult::NeedMore
        );
    }

    #[test]
    fn test_predict_request_fc1_2_3_4_5_6_fixed_8() {
        for fc in [0x01u8, 0x02, 0x03, 0x04, 0x05, 0x06] {
            assert_eq!(
                predict_rtu_frame_length(&[0x01, fc], false),
                PredictResult::Length(8),
                "request fc=0x{:02x} should predict 8",
                fc
            );
        }
    }

    #[test]
    fn test_predict_request_fc17_fixed_4() {
        assert_eq!(
            predict_rtu_frame_length(&[0x01, 0x11], false),
            PredictResult::Length(4)
        );
    }

    #[test]
    fn test_predict_request_fc22_fixed_10() {
        assert_eq!(
            predict_rtu_frame_length(&[0x01, 0x16], false),
            PredictResult::Length(10)
        );
    }

    #[test]
    fn test_predict_request_fc43_fixed_7() {
        assert_eq!(
            predict_rtu_frame_length(&[0x01, 0x2b], false),
            PredictResult::Length(7)
        );
    }

    #[test]
    fn test_predict_request_fc15_byte_count() {
        let buf = [0x01u8, 0x0f, 0x00, 0x00, 0x00, 0x0a, 0x02];
        assert_eq!(
            predict_rtu_frame_length(&buf, false),
            PredictResult::Length(11)
        );
    }

    #[test]
    fn test_predict_request_fc16_byte_count() {
        let buf = [0x01u8, 0x10, 0x00, 0x00, 0x00, 0x02, 0x04];
        assert_eq!(
            predict_rtu_frame_length(&buf, false),
            PredictResult::Length(13)
        );
    }

    #[test]
    fn test_predict_request_fc23_byte_count() {
        let buf = [
            0x01u8, 0x17, 0x00, 0x00, 0x00, 0x02, 0x00, 0x02, 0x00, 0x01, 0x02,
        ];
        assert_eq!(
            predict_rtu_frame_length(&buf, false),
            PredictResult::Length(15)
        );
    }

    #[test]
    fn test_predict_request_byte_count_buffer_too_short() {
        let buf = [0x01u8, 0x0f, 0x00, 0x00, 0x00, 0x0a];
        assert_eq!(
            predict_rtu_frame_length(&buf, false),
            PredictResult::NeedMore
        );
    }

    #[test]
    fn test_predict_response_exception_returns_5() {
        assert_eq!(
            predict_rtu_frame_length(&[0x01, 0x83], true),
            PredictResult::Length(5)
        );
        assert_eq!(
            predict_rtu_frame_length(&[0x01, 0x90], true),
            PredictResult::Length(5)
        );
        assert_eq!(
            predict_rtu_frame_length(&[0x01, 0xab], true),
            PredictResult::Length(5)
        );
    }

    #[test]
    fn test_predict_response_fc5_6_15_16_fixed_8() {
        for fc in [0x05u8, 0x06, 0x0f, 0x10] {
            assert_eq!(
                predict_rtu_frame_length(&[0x01, fc], true),
                PredictResult::Length(8),
                "response fc=0x{:02x} should predict 8",
                fc
            );
        }
    }

    #[test]
    fn test_predict_response_fc22_fixed_10() {
        assert_eq!(
            predict_rtu_frame_length(&[0x01, 0x16], true),
            PredictResult::Length(10)
        );
    }

    #[test]
    fn test_predict_response_fc1_2_3_4_byte_count() {
        for fc in [0x01u8, 0x02, 0x03, 0x04] {
            let buf = [0x01u8, fc, 0x04, 0x00, 0x00, 0x00, 0x00];
            assert_eq!(
                predict_rtu_frame_length(&buf, true),
                PredictResult::Length(9),
                "response fc=0x{:02x} byte_count=4 should predict 9",
                fc
            );
        }
    }

    #[test]
    fn test_predict_response_fc17_byte_count() {
        let buf = [0x01u8, 0x11, 0x03];
        assert_eq!(
            predict_rtu_frame_length(&buf, true),
            PredictResult::Length(8)
        );
    }

    #[test]
    fn test_predict_response_fc23_byte_count() {
        let buf = [0x01u8, 0x17, 0x04];
        assert_eq!(
            predict_rtu_frame_length(&buf, true),
            PredictResult::Length(9)
        );
    }

    #[test]
    fn test_predict_response_byte_count_buffer_too_short() {
        let buf = [0x01u8, 0x03];
        assert_eq!(
            predict_rtu_frame_length(&buf, true),
            PredictResult::NeedMore
        );
    }

    #[test]
    fn test_predict_unknown_fc_returns_unknown() {
        assert_eq!(
            predict_rtu_frame_length(&[0x01, 0x99], false),
            PredictResult::Unknown
        );
    }

    #[test]
    fn test_predict_request_exception_path_not_taken() {
        // In a request, fc with high bit set is NOT treated as exception
        // (only responses use that path)
        assert_eq!(
            predict_rtu_frame_length(&[0x01, 0x83], false),
            PredictResult::Unknown
        );
    }

    // ===== predict_rtu_frame_length: FC 0x2B response (MEI 0x0E) =====

    #[test]
    fn test_predict_fc43_response_needs_at_least_8_bytes() {
        for buf in &[
            &[][..],
            &[0x01],
            &[0x01, 0x2b],
            &[0x01, 0x2b, 0x0e, 0x01, 0x83, 0x00, 0x00],
        ] {
            assert_eq!(
                predict_rtu_frame_length(buf, true),
                PredictResult::NeedMore,
                "buf len {} should be NeedMore",
                buf.len()
            );
        }
    }

    #[test]
    fn test_predict_fc43_response_unsupported_mei_is_unknown() {
        // MEI != 0x0E (e.g. CANopen 0x0D) — not handled by built-in predictor.
        let buf = [0x01u8, 0x2b, 0x0d, 0x01, 0x00, 0x00, 0x00, 0x00];
        assert_eq!(predict_rtu_frame_length(&buf, true), PredictResult::Unknown);
    }

    #[test]
    fn test_predict_fc43_response_zero_objects() {
        // unit fc mei rdic conformity more nextId numObjs=0 [+ CRC(2)]
        let buf = [0x01u8, 0x2b, 0x0e, 0x01, 0x81, 0x00, 0x00, 0x00];
        // No objects, total length = 8 + 2 = 10
        assert_eq!(
            predict_rtu_frame_length(&buf, true),
            PredictResult::Length(10)
        );
    }

    #[test]
    fn test_predict_fc43_response_one_object() {
        // 1 object: id=0x00, len=4, data="abcd"
        let buf = [
            0x01u8, 0x2b, 0x0e, 0x01, 0x81, 0x00, 0x00, 0x01, 0x00, 0x04, b'a', b'b', b'c', b'd',
        ];
        // 8 header + (2 + 4) object + 2 CRC = 16
        assert_eq!(
            predict_rtu_frame_length(&buf, true),
            PredictResult::Length(16)
        );
    }

    #[test]
    fn test_predict_fc43_response_object_tail_missing_is_need_more() {
        // numObjs=1 declared, but only header + object-id present
        let buf = [0x01u8, 0x2b, 0x0e, 0x01, 0x81, 0x00, 0x00, 0x01, 0x00];
        assert_eq!(
            predict_rtu_frame_length(&buf, true),
            PredictResult::NeedMore
        );
    }

    // ===== is_standard_fc =====

    #[test]
    fn test_is_standard_fc_known_request() {
        assert!(is_standard_fc(0x03, false));
        assert!(is_standard_fc(0x0f, false));
        assert!(is_standard_fc(0x2b, false));
    }

    #[test]
    fn test_is_standard_fc_known_response() {
        assert!(is_standard_fc(0x03, true));
        assert!(is_standard_fc(0x2b, true));
    }

    #[test]
    fn test_is_standard_fc_exception_response() {
        assert!(is_standard_fc(0x83, true));
        assert!(is_standard_fc(0xab, true));
    }

    #[test]
    fn test_is_standard_fc_unknown_returns_false() {
        assert!(!is_standard_fc(0x65, false));
        // 0x64 has high bit clear and is not a known FC → false.
        assert!(!is_standard_fc(0x64, true));
    }

    // ===== gen_connection_id =====

    #[test]
    fn test_gen_connection_id_has_prefix() {
        let id = gen_connection_id("serial");
        assert!(id.starts_with("serial-"), "got {}", id);
    }

    #[test]
    fn test_gen_connection_id_is_unique() {
        let a = gen_connection_id("tcp-server");
        let b = gen_connection_id("tcp-server");
        assert_ne!(a, b);
    }

    #[test]
    fn test_gen_connection_id_different_prefixes() {
        let a = gen_connection_id("a");
        let b = gen_connection_id("b");
        assert!(a.starts_with("a-"));
        assert!(b.starts_with("b-"));
    }

    #[test]
    fn test_gen_connection_id_uuid_v4_format() {
        let id = gen_connection_id("tcp");
        // Format: tcp-{uuid-v4} where uuid is 8-4-4-4-12 hex chars
        let uuid_part = id.strip_prefix("tcp-").unwrap();
        let parts: Vec<&str> = uuid_part.split('-').collect();
        assert_eq!(parts.len(), 5, "expected 5 hyphen-separated parts");
        assert_eq!(parts[0].len(), 8);
        assert_eq!(parts[1].len(), 4);
        assert_eq!(parts[2].len(), 4);
        assert_eq!(parts[3].len(), 4);
        assert_eq!(parts[4].len(), 12);
        // Version nibble must be 4
        assert_eq!(
            parts[2].chars().next().unwrap(),
            '4',
            "UUID version nibble must be 4"
        );
        // Variant nibble must be 8, 9, a, or b
        let variant = parts[3].chars().next().unwrap();
        assert!(
            matches!(variant, '8' | '9' | 'a' | 'b'),
            "UUID variant nibble must be 8-9-a-b, got {}",
            variant
        );
    }
}