canboat 9.1.0

NMEA 2000 decoding, encoding and live-bus access: the CANboat PGN database as a library, plus the canboat CLI (convert / interface / server / tui).
Documentation
// (C) 2009-2026, Kees Verruijt, Harlingen, The Netherlands.

//! Fast-packet vs single-frame classification for outbound PGN encoding
//! and inbound reassembly.
//!
//! Single-frame PGNs fit in one 8-byte CAN frame and decode against the
//! schema verbatim. Fast-packet PGNs are framed with a one-byte
//! `seq | index` header on every CAN frame, a one-byte length on the
//! first, and 0xff padding on the last to round to 8 bytes. Picking the
//! wrong framing on either path silently corrupts every field — see
//! `device::socketcan::send_pgn` for the encoder side and
//! `socketcan-serial`'s reassembler for the decoder side.
//!
//! Range rules:
//!
//! | PGN range            | Framing                                                  |
//! |----------------------|----------------------------------------------------------|
//! | `0x00000..0x10000`   | Single (J1939 PDU1/PDU2 base)                            |
//! | `0x10000..0x1F000`   | Fast (NMEA 2000 fast-packet-only span)                   |
//! | `0x1F000..0x1FFFF`   | Mixed — consult [`FASTPACKET_MIXED`]                     |
//! | `0x1FFFF..0x20000`   | Single (technically out of fast-packet space)            |
//! | `>= 0x40000` (BEM)   | Fast (synthetic CANboat PGNs, never on the wire)         |
//!
//! The mixed-range table is generated by `keel` from `database/` and
//! committed as `fastpacket_generated.rs`.
//!
//! It is not the same information as the engine's schema, which is why
//! it is a table of its own rather than a lookup: only 239 of the 4096 PGNs
//! in this range have a definition at all. For the other 3857 the table
//! carries a *range default* — fast at or above 0x1FF00, single below — and
//! asking the database instead would fall through to a catch-all and take
//! that catch-all's framing, which is a different rule.

use crate::engine::{FASTPACKET_MAX_SIZE, FramePacketType};

include!("fastpacket_generated.rs");

/// Synthetic-PGN marker (CANBOAT_BEM in canboat C). Above this we
/// treat anything as fast-packet, since the synthetic frames we
/// generate internally are by definition coalesced multi-byte
/// payloads.
pub const CANBOAT_PGN_START: u32 = 0x40000;

/// Classify `pgn` as fast-packet or single-frame. Mirrors canboat C's
/// `isFastPacket()` plus the synthetic-PGN extension.
pub fn packet_type(pgn: u32) -> FramePacketType {
    if (FASTPACKET_MIXED_START..FASTPACKET_MIXED_END).contains(&pgn) {
        return if FASTPACKET_MIXED[(pgn - FASTPACKET_MIXED_START) as usize] {
            FramePacketType::Fast
        } else {
            FramePacketType::Single
        };
    }
    if (0x10000..FASTPACKET_MIXED_START).contains(&pgn) {
        return FramePacketType::Fast;
    }
    if pgn >= CANBOAT_PGN_START {
        return FramePacketType::Fast;
    }
    FramePacketType::Single
}

/// Split a coalesced fast-packet payload into its ISO 11783-3 wire
/// frames: every frame is exactly 8 bytes, byte 0 is
/// `(seq << 5) | index`, frame 0 carries the total length in byte 1,
/// and the last frame is padded with 0xff. The caller owns the
/// per-(pgn, src) sequence counter. Both TX paths — socketcan and the
/// line gateways — fragment through here.
///
/// `None` past [`FASTPACKET_MAX_SIZE`] (223) bytes: the frame index has
/// five bits, so a 33rd frame would wrap to index 0.
pub fn fragment(seq: u8, data: &[u8]) -> Option<Vec<[u8; 8]>> {
    if data.len() > FASTPACKET_MAX_SIZE {
        return None;
    }
    let mut out = Vec::with_capacity(data.len() / 7 + 1);
    let mut index: u8 = 0;
    let mut remaining = data.len();
    let mut offset = 0;
    while remaining > 0 || index == 0 {
        let mut frame = [0xffu8; 8];
        frame[0] = (seq << 5) | (index & 0x1f);
        let (payload_start, chunk) = if index == 0 {
            frame[1] = data.len() as u8;
            (2, remaining.min(6))
        } else {
            (1, remaining.min(7))
        };
        frame[payload_start..payload_start + chunk].copy_from_slice(&data[offset..offset + chunk]);
        out.push(frame);
        offset += chunk;
        remaining -= chunk;
        index = index.wrapping_add(1);
        if remaining == 0 {
            break;
        }
    }
    Some(out)
}

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

    /// 223 bytes is exactly 32 frames (6 + 31 × 7), indexes 0..=31; one
    /// byte more would need a 33rd frame whose index wraps to 0.
    #[test]
    fn fragment_fills_32_frames_and_refuses_more() {
        let data = [0xab; FASTPACKET_MAX_SIZE];
        let frames = fragment(5, &data).expect("223 bytes fit");
        assert_eq!(frames.len(), 32);
        assert_eq!(frames[0][..2], [5 << 5, 223]);
        assert_eq!(frames[31][0], (5 << 5) | 31);
        assert_eq!(frames[31][1..], [0xab; 7]);
        assert_eq!(fragment(5, &[0xab; FASTPACKET_MAX_SIZE + 1]), None);
    }

    /// PGN 127508 (Battery Status) sits in the mixed range and per
    /// `docs/canboat.json` is **single-frame**, not fast — the table
    /// must reflect that, otherwise our encoder wraps an 8-byte
    /// payload in a fast-packet shell and the receiver decodes
    /// garbage. This is the regression test for the original bug.
    #[test]
    fn pgn_127508_battery_status_is_single() {
        assert_eq!(packet_type(127508), FramePacketType::Single);
    }

    /// PGN 127506 (DC Detailed Status) is fast-packet per the spec
    /// and per `docs/canboat.json`. Sanity check the table the other
    /// way too.
    #[test]
    fn pgn_127506_dc_detailed_is_fast() {
        assert_eq!(packet_type(127506), FramePacketType::Fast);
    }

    /// PGN 126996 (Product Information) is in the fast-packet-only
    /// range below the mixed band; classifier should resolve it
    /// without touching the table.
    #[test]
    fn pgn_126996_product_info_is_fast() {
        assert_eq!(packet_type(126996), FramePacketType::Fast);
    }

    /// PGN 60928 (ISO Address Claim) is below the fast-packet base,
    /// so it falls through to Single.
    #[test]
    fn pgn_60928_address_claim_is_single() {
        assert_eq!(packet_type(60928), FramePacketType::Single);
    }

    /// PGN 126993 (Heartbeat) is in the mixed range and is single-
    /// frame per the spec — table-backed classification must agree.
    #[test]
    fn pgn_126993_heartbeat_is_single() {
        assert_eq!(packet_type(126993), FramePacketType::Single);
    }
}