use std::collections::HashMap;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use crate::engine::encode::{EncodeError, Raw};
use crate::engine::{ADDR_GLOBAL, DecodedPgn, PgnDatabase, RawFrame, Units, field};
const PGN_ISO_REQUEST: u32 = 59904;
const PGN_ISO_ADDRESS_CLAIM: u32 = 60928;
const PGN_PRODUCT_INFO: u32 = 126996;
const FURUNO_MFG: u16 = 1855;
const NAV_CLASS: u8 = 60;
const SCX20_FUNCTION: u8 = 140;
const MIN_SYNTH_GAP: Duration = Duration::from_secs(1);
const SCX20_DB_VERSION: i64 = 2100;
const SCX20_PRODUCT_CODE: i64 = 10571;
const SCX20_MODEL_ID: &str = "SCX-20";
const SCX20_SOFTWARE_VERSION: &str = "2051601-01.04";
const SCX20_MODEL_VERSION: &str = "20P8235-00";
const SCX20_MODEL_SERIAL: &str = "100118101056";
const SCX20_CERTIFICATION_LEVEL: i64 = 2;
const SCX20_LOAD_EQUIVALENCY: i64 = 4;
pub struct Scx20 {
learned: HashMap<u8, Instant>,
}
impl Default for Scx20 {
fn default() -> Self {
Self::new()
}
}
impl Scx20 {
pub fn new() -> Self {
Self {
learned: HashMap::new(),
}
}
pub fn process(&mut self, d: &DecodedPgn, now: Instant) -> Vec<RawFrame> {
if d.pgn == PGN_ISO_ADDRESS_CLAIM {
if let Some(name) = d.iso_name()
&& is_scx20_name(name)
{
self.learned
.entry(d.src)
.or_insert_with(|| now - MIN_SYNTH_GAP);
}
return Vec::new();
}
if d.pgn != PGN_ISO_REQUEST {
return Vec::new();
}
let target = d
.field(field::iso_request::PGN)
.and_then(|f| f.value.as_i64());
if target != Some(i64::from(PGN_PRODUCT_INFO)) {
return Vec::new();
}
let mut targets: Vec<u8> = if d.dst == ADDR_GLOBAL {
self.learned.keys().copied().collect()
} else if self.learned.contains_key(&d.dst) {
vec![d.dst]
} else {
return Vec::new();
};
targets.sort_unstable();
let mut out = Vec::with_capacity(targets.len());
for src in targets {
let last = self
.learned
.get_mut(&src)
.expect("targets came from self.learned");
if now.duration_since(*last) < MIN_SYNTH_GAP {
continue;
}
*last = now;
log::warn!(
"quirk(scx20): fabricating PGN 126996 from src={src} \
in response to PGN 59904 request {}->{}",
d.src,
d.dst,
);
if let Some(frame) = build_scx20_product_info(src) {
out.push(frame);
}
}
out
}
}
fn is_scx20_name(name: u64) -> bool {
let mfg = ((name >> 21) & 0x7FF) as u16;
let function = ((name >> 40) & 0xFF) as u8;
let class = ((name >> 49) & 0x7F) as u8;
mfg == FURUNO_MFG && class == NAV_CLASS && function == SCX20_FUNCTION
}
fn zero_padded(fr: crate::engine::FieldRef, text: &str) -> Vec<u8> {
let len = (fr.field.bit_length.unwrap_or(0) / 8) as usize;
let mut buf = vec![0u8; len];
let n = text.len().min(len);
buf[..n].copy_from_slice(&text.as_bytes()[..n]);
buf
}
fn build_scx20_product_info(src: u8) -> Option<RawFrame> {
let db = PgnDatabase::embedded(Units::Si);
let build = || -> Result<RawFrame, EncodeError> {
let mut b = db
.encode("productInformation")?
.priority(6)
.source(src)
.destination(ADDR_GLOBAL)
.timestamp(now_iso());
use field::product_information as pi;
b.push(pi::NMEA2000_VERSION, Raw(SCX20_DB_VERSION as u64))?;
b.push(pi::PRODUCT_CODE, SCX20_PRODUCT_CODE)?;
b.push(pi::MODEL_ID, zero_padded(pi::MODEL_ID, SCX20_MODEL_ID))?;
b.push(
pi::SOFTWARE_VERSION_CODE,
zero_padded(pi::SOFTWARE_VERSION_CODE, SCX20_SOFTWARE_VERSION),
)?;
b.push(
pi::MODEL_VERSION,
zero_padded(pi::MODEL_VERSION, SCX20_MODEL_VERSION),
)?;
b.push(
pi::MODEL_SERIAL_CODE,
zero_padded(pi::MODEL_SERIAL_CODE, SCX20_MODEL_SERIAL),
)?;
b.push(pi::CERTIFICATION_LEVEL, SCX20_CERTIFICATION_LEVEL)?;
b.push(pi::LOAD_EQUIVALENCY, SCX20_LOAD_EQUIVALENCY)?;
b.build()
};
match build() {
Ok(frame) => Some(frame),
Err(e) => {
log::error!("quirk(scx20): failed to encode PGN 126996: {e}");
None
}
}
}
fn now_iso() -> String {
let ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
crate::engine::format_iso_ms(ms)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::{PgnDatabase, Units};
const SCX20_NAME_BYTES: [u8; 8] = [0x64, 0x43, 0xe0, 0xe7, 0x00, 0x8c, 0x78, 0xc0];
fn name_le(bytes: [u8; 8]) -> u64 {
u64::from_le_bytes(bytes)
}
fn db() -> &'static PgnDatabase {
PgnDatabase::embedded(Units::Si)
}
fn decode(frame: &RawFrame) -> DecodedPgn {
db().decode(frame).expect("decodes")
}
fn claim(src: u8, name: [u8; 8]) -> DecodedPgn {
decode(&RawFrame::new(
None,
6,
PGN_ISO_ADDRESS_CLAIM,
src,
ADDR_GLOBAL,
name,
))
}
fn iso_request(src: u8, dst: u8, target_pgn: u32) -> DecodedPgn {
let data = [
target_pgn as u8,
(target_pgn >> 8) as u8,
(target_pgn >> 16) as u8,
];
decode(&RawFrame::new(None, 6, PGN_ISO_REQUEST, src, dst, data))
}
const CAPTURED_PRODUCT_INFO: [u8; 134] = captured_product_info();
const fn captured_product_info() -> [u8; 134] {
let mut d = [0u8; 134];
d[0] = 0x34; d[1] = 0x08;
d[2] = 0x4b; d[3] = 0x29;
copy_bytes(&mut d, 4, b"SCX-20");
copy_bytes(&mut d, 36, b"2051601-01.04");
copy_bytes(&mut d, 68, b"20P8235-00");
copy_bytes(&mut d, 100, b"100118101056");
d[132] = 0x02; d[133] = 0x04; d
}
const fn copy_bytes(dst: &mut [u8; 134], off: usize, s: &[u8]) {
let mut i = 0;
while i < s.len() {
dst[off + i] = s[i];
i += 1;
}
}
#[test]
fn built_product_info_matches_captured_payload() {
let f = build_scx20_product_info(52).expect("encodes");
assert_eq!(f.data.as_slice(), &CAPTURED_PRODUCT_INFO);
}
#[test]
fn name_match_accepts_captured_scx20() {
assert!(is_scx20_name(name_le(SCX20_NAME_BYTES)));
}
#[test]
fn name_match_rejects_non_scx20() {
let mut other = SCX20_NAME_BYTES;
other[5] = 0x8b; assert!(!is_scx20_name(name_le(other)));
let navico_navigation_140: u64 = (17252_u64)
| (275_u64 << 21)
| (140_u64 << 40)
| (60_u64 << 49)
| (4_u64 << 60)
| (1_u64 << 63);
assert!(!is_scx20_name(navico_navigation_140));
let furuno_instrumentation: u64 = (17252_u64)
| (1855_u64 << 21)
| (140_u64 << 40)
| (80_u64 << 49) | (4_u64 << 60)
| (1_u64 << 63);
assert!(!is_scx20_name(furuno_instrumentation));
}
#[test]
fn product_info_frame_header_is_correct() {
let f = build_scx20_product_info(52).expect("encodes");
assert_eq!(f.prio, 6);
assert_eq!(f.pgn, PGN_PRODUCT_INFO);
assert_eq!(f.src, 52);
assert_eq!(f.dst, ADDR_GLOBAL);
assert_eq!(f.data.len(), 134);
assert!(f.timestamp.is_some());
}
#[test]
fn address_claim_alone_does_not_synthesise() {
let mut q = Scx20::new();
let out = q.process(&claim(52, SCX20_NAME_BYTES), Instant::now());
assert!(
out.is_empty(),
"Address Claim is a discovery channel, not a trigger"
);
}
#[test]
fn request_before_any_claim_is_ignored() {
let mut q = Scx20::new();
let out = q.process(
&iso_request(7, ADDR_GLOBAL, PGN_PRODUCT_INFO),
Instant::now(),
);
assert!(out.is_empty());
}
#[test]
fn broadcast_request_after_claim_synthesises_one_per_unit() {
let mut q = Scx20::new();
let t0 = Instant::now();
q.process(&claim(52, SCX20_NAME_BYTES), t0);
q.process(&claim(99, SCX20_NAME_BYTES), t0);
let out = q.process(&iso_request(7, ADDR_GLOBAL, PGN_PRODUCT_INFO), t0);
let srcs: Vec<u8> = out.iter().map(|f| f.src).collect();
assert_eq!(srcs, vec![52, 99]);
assert!(out.iter().all(|f| f.pgn == PGN_PRODUCT_INFO));
assert!(out.iter().all(|f| f.dst == ADDR_GLOBAL));
}
#[test]
fn directed_request_to_unknown_dst_is_ignored() {
let mut q = Scx20::new();
let t0 = Instant::now();
q.process(&claim(52, SCX20_NAME_BYTES), t0);
let out = q.process(&iso_request(7, 99, PGN_PRODUCT_INFO), t0);
assert!(out.is_empty());
}
#[test]
fn directed_request_to_known_dst_synthesises() {
let mut q = Scx20::new();
let t0 = Instant::now();
q.process(&claim(52, SCX20_NAME_BYTES), t0);
let out = q.process(&iso_request(7, 52, PGN_PRODUCT_INFO), t0);
assert_eq!(out.len(), 1);
assert_eq!(out[0].src, 52);
}
#[test]
fn rate_limited_within_one_second() {
let mut q = Scx20::new();
let t0 = Instant::now();
q.process(&claim(52, SCX20_NAME_BYTES), t0);
let out = q.process(&iso_request(7, 52, PGN_PRODUCT_INFO), t0);
assert_eq!(out.len(), 1);
let out = q.process(
&iso_request(7, 52, PGN_PRODUCT_INFO),
t0 + Duration::from_millis(500),
);
assert!(
out.is_empty(),
"two requests within {MIN_SYNTH_GAP:?} must collapse"
);
let out = q.process(
&iso_request(7, 52, PGN_PRODUCT_INFO),
t0 + MIN_SYNTH_GAP + Duration::from_millis(1),
);
assert_eq!(out.len(), 1);
}
#[test]
fn request_for_other_pgn_is_ignored() {
let mut q = Scx20::new();
let t0 = Instant::now();
q.process(&claim(52, SCX20_NAME_BYTES), t0);
let out = q.process(&iso_request(7, 52, PGN_ISO_ADDRESS_CLAIM), t0);
assert!(out.is_empty());
}
}