use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};
use crate::engine::RawFrame;
pub const PGN_CLAIM: u32 = 60928;
pub const PGN_PROD_INFO: u32 = 126996;
pub const PGN_CONFIG_INFO: u32 = 126998;
pub const PGN_ISO_REQUEST: u32 = 59904;
pub const DEVICE_REQUEST_SPACING: Duration = Duration::from_secs(1);
pub const DEVICE_REQUEST_INTERVAL: Duration = Duration::from_secs(300);
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum RequestKind {
Claim,
ProductInfo,
}
#[derive(Default, Debug, Clone, Copy)]
struct DeviceState {
seen: bool,
last_claim_received: Option<Instant>,
last_claim_requested: Option<Instant>,
last_prod_info_received: Option<Instant>,
last_prod_info_requested: Option<Instant>,
}
pub struct RequestEngine {
devices: Mutex<HashMap<u8, DeviceState>>,
}
impl RequestEngine {
pub fn new() -> Self {
Self {
devices: Mutex::new(HashMap::new()),
}
}
pub fn note_device_seen(&self, pgn: u32, src: u8) {
let now = Instant::now();
let mut devs = self.devices.lock().unwrap();
let entry = devs.entry(src).or_default();
entry.seen = true;
if pgn == PGN_CLAIM {
entry.last_claim_received = Some(now);
} else if pgn == PGN_PROD_INFO {
entry.last_prod_info_received = Some(now);
}
}
#[cfg(test)]
pub fn device_count(&self) -> usize {
self.devices.lock().map(|m| m.len()).unwrap_or(0)
}
pub fn next_due(&self, cursor: &mut u8, kind: RequestKind) -> Option<u8> {
let now = Instant::now();
let devs = self.devices.lock().ok()?;
for offset in 0..=u8::MAX as u16 {
let idx = cursor.wrapping_add(offset as u8);
let Some(state) = devs.get(&idx) else {
continue;
};
if !state.seen {
continue;
}
let (last_received, last_requested) = match kind {
RequestKind::Claim => (state.last_claim_received, state.last_claim_requested),
RequestKind::ProductInfo => (
state.last_prod_info_received,
state.last_prod_info_requested,
),
};
if last_received.is_some_and(|t| now.duration_since(t) < DEVICE_REQUEST_INTERVAL) {
continue;
}
if last_requested.is_some_and(|t| now.duration_since(t) < DEVICE_REQUEST_INTERVAL) {
continue;
}
drop(devs);
let mut devs = self.devices.lock().unwrap();
if let Some(state) = devs.get_mut(&idx) {
match kind {
RequestKind::Claim => state.last_claim_requested = Some(now),
RequestKind::ProductInfo => state.last_prod_info_requested = Some(now),
}
}
*cursor = idx.wrapping_add(1);
return Some(idx);
}
None
}
}
impl Default for RequestEngine {
fn default() -> Self {
Self::new()
}
}
pub fn iso_request_payload(pgn: u32) -> [u8; 3] {
[
(pgn & 0xff) as u8,
((pgn >> 8) & 0xff) as u8,
((pgn >> 16) & 0xff) as u8,
]
}
pub fn format_iso_request_plain(timestamp: &str, dst: u8, pgn: u32) -> String {
let [b0, b1, b2] = iso_request_payload(pgn);
format!("{timestamp},6,59904,0,{dst},3,{b0:02x},{b1:02x},{b2:02x}")
}
pub fn iso_request_frame(src: u8, dst: u8, pgn: u32) -> RawFrame {
RawFrame::new(None, 6, PGN_ISO_REQUEST, src, dst, iso_request_payload(pgn))
}
pub fn spawn(engine: Arc<RequestEngine>, emit: impl Fn(u8, u32) + Send + 'static) {
thread::Builder::new()
.name("n2kd-claim-engine".into())
.spawn(move || {
let mut next_claim = 0u8;
let mut next_prod = 0u8;
let mut last_claim_emit = Instant::now() - DEVICE_REQUEST_SPACING;
let mut last_prod_emit = Instant::now() - DEVICE_REQUEST_SPACING;
loop {
thread::sleep(Duration::from_millis(500));
let now = Instant::now();
if now.duration_since(last_claim_emit) >= DEVICE_REQUEST_SPACING
&& let Some(dst) = engine.next_due(&mut next_claim, RequestKind::Claim)
{
emit(dst, PGN_CLAIM);
last_claim_emit = now;
}
if now.duration_since(last_prod_emit) >= DEVICE_REQUEST_SPACING
&& let Some(dst) = engine.next_due(&mut next_prod, RequestKind::ProductInfo)
{
emit(dst, PGN_PROD_INFO);
emit(dst, PGN_CONFIG_INFO);
last_prod_emit = now;
}
}
})
.ok();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn iso_request_payload_is_little_endian() {
assert_eq!(iso_request_payload(60928), [0x00, 0xee, 0x00]);
assert_eq!(iso_request_payload(126996), [0x14, 0xf0, 0x01]);
assert_eq!(iso_request_payload(126998), [0x16, 0xf0, 0x01]);
}
#[test]
fn format_iso_request_plain_matches_canboat_shape() {
assert_eq!(
format_iso_request_plain("2026-01-01T00:00:00.000Z", 35, 60928),
"2026-01-01T00:00:00.000Z,6,59904,0,35,3,00,ee,00"
);
}
#[test]
fn iso_request_frame_carries_correct_header() {
let f = iso_request_frame(0, 17, 126996);
assert_eq!(f.prio, 6);
assert_eq!(f.pgn, PGN_ISO_REQUEST);
assert_eq!(f.src, 0);
assert_eq!(f.dst, 17);
assert_eq!(f.data.as_slice(), &[0x14, 0xf0, 0x01]);
}
#[test]
fn device_count_tracks_distinct_sources() {
let e = RequestEngine::new();
assert_eq!(e.device_count(), 0);
e.note_device_seen(127251, 7); e.note_device_seen(127257, 7); assert_eq!(e.device_count(), 1, "same src must not double-count");
e.note_device_seen(127251, 23); assert_eq!(e.device_count(), 2);
}
#[test]
fn next_due_returns_seen_device_then_marks_it_requested() {
let e = RequestEngine::new();
e.note_device_seen(127251, 9); let mut cursor = 0u8;
let first = e.next_due(&mut cursor, RequestKind::Claim);
assert_eq!(first, Some(9), "device 9 was seen and never asked");
assert_eq!(cursor, 10, "cursor must advance past the returned src");
cursor = 0;
let second = e.next_due(&mut cursor, RequestKind::Claim);
assert_eq!(second, None, "recently-requested device must be skipped");
}
#[test]
fn next_due_skips_unseen_entries() {
let e = RequestEngine::new();
e.note_device_seen(127257, 5);
let mut cursor = 0u8;
assert_eq!(e.next_due(&mut cursor, RequestKind::Claim), Some(5));
}
#[test]
fn next_due_kinds_are_independent() {
let e = RequestEngine::new();
e.note_device_seen(127251, 12);
let mut c = 0u8;
assert_eq!(e.next_due(&mut c, RequestKind::Claim), Some(12));
let mut c = 0u8;
assert_eq!(
e.next_due(&mut c, RequestKind::ProductInfo),
Some(12),
"ProductInfo slot must still be due after a Claim request"
);
}
#[test]
fn next_due_recent_received_suppresses_request() {
let e = RequestEngine::new();
e.note_device_seen(PGN_CLAIM, 42);
let mut cursor = 0u8;
assert_eq!(
e.next_due(&mut cursor, RequestKind::Claim),
None,
"fresh PGN 60928 receipt must satisfy the Claim schedule"
);
let mut cursor = 0u8;
assert_eq!(e.next_due(&mut cursor, RequestKind::ProductInfo), Some(42));
}
#[test]
fn next_due_round_robins_across_seen_devices() {
let e = RequestEngine::new();
e.note_device_seen(127251, 3);
e.note_device_seen(127251, 11);
e.note_device_seen(127251, 200);
let mut cursor = 0u8;
let first = e.next_due(&mut cursor, RequestKind::Claim).unwrap();
let second = e.next_due(&mut cursor, RequestKind::Claim).unwrap();
let third = e.next_due(&mut cursor, RequestKind::Claim).unwrap();
let mut hits = [first, second, third];
hits.sort();
assert_eq!(hits, [3, 11, 200]);
assert_eq!(e.next_due(&mut cursor, RequestKind::Claim), None);
}
}