use std::time::Instant;
use crate::engine::{ADDR_GLOBAL, DecodedPgn, RawFrame, field};
use crate::io::address_claim::AddressClaim;
use crate::io::nmea_responder::{self, ProductInfo};
const PGN_ISO_REQUEST: u32 = 59904;
const PGN_ISO_ADDRESS_CLAIM: u32 = 60928;
const PGN_PGN_LIST: u32 = 126464;
const PGN_PRODUCT_INFO: u32 = 126996;
const PGN_BG_RATES: u32 = 130824;
const PGN_BG_RATES_PRIO: u8 = 3;
const PGN_SCX_6DOF: u32 = 130842;
const MFG_FURUNO: u16 = 1855;
const KEY_ROLL_RATE: u16 = 60;
const KEY_PITCH_RATE: u16 = 158; const KEY_YAW_RATE: u16 = 68;
const SCX_RATE_LSB_RAD_S: f64 = 0.001 * std::f64::consts::PI / 180.0;
const BG_RATE_LSB_RAD_S: f64 = 3.125e-8;
const MFG_BANDG: u16 = 381;
const DEVICE_FUNCTION: u8 = 140; const DEVICE_CLASS: u8 = 60; const INDUSTRY_MARINE: u16 = 4;
const PREFERRED_ADDRESS: u8 = 24;
const PRODUCT_CODE: i64 = 21488; const DB_VERSION: i64 = 2100; const MODEL_ID: &str = "H5000 Motion Sensor";
const SOFTWARE_VERSION: &str = "1.2.4";
const MODEL_VERSION: &str = "Motion";
const MODEL_SERIAL: &str = "canboat";
const CERTIFICATION_LEVEL: i64 = 2;
const LOAD_EQUIVALENCY: i64 = 1;
const TX_PGN_LIST: [u32; 4] = [
PGN_ISO_ADDRESS_CLAIM,
PGN_PRODUCT_INFO,
PGN_PGN_LIST,
PGN_BG_RATES,
];
const RX_PGN_LIST: [u32; 3] = [PGN_ISO_REQUEST, PGN_ISO_ADDRESS_CLAIM, PGN_SCX_6DOF];
pub struct Motion {
claim: AddressClaim,
base: Option<Instant>,
started: bool,
rate_source: Option<u8>,
}
impl Motion {
pub fn new(state_dir: Option<&std::path::Path>) -> Self {
Self {
claim: AddressClaim::new(build_name(state_dir), PREFERRED_ADDRESS, true),
base: None,
started: false,
rate_source: None,
}
}
pub fn set_txt(&self, txt: &crate::server::mdns::Txt) {
txt.set(
"motion.name",
Some(crate::server::mdns::name_hex(self.claim.name())),
);
}
pub fn process(&mut self, d: &DecodedPgn, now: Instant) -> Vec<RawFrame> {
let base = *self.base.get_or_insert(now);
let now_ms = now.saturating_duration_since(base).as_millis() as u64;
let mut out = Vec::new();
if !self.started {
self.started = true;
log::info!(
"quirk(motion): starting H5000 Motion Sensor impersonation \
(claiming address, preferred {PREFERRED_ADDRESS})"
);
out.extend(self.claim.start(now_ms));
}
let was_claimed = self.claim.is_claimed();
match d.pgn {
PGN_ISO_ADDRESS_CLAIM => {
if let Some(their_name) = d.iso_name() {
out.extend(self.claim.on_address_claim(now_ms, d.src, their_name));
}
}
PGN_ISO_REQUEST => out.extend(self.handle_request(d)),
PGN_SCX_6DOF => {
if let Some(f) = self.maybe_emit_rates(d) {
out.push(f);
}
}
_ => {}
}
out.extend(self.claim.tick(now_ms));
if !was_claimed && let Some(addr) = self.claim.address() {
log::info!(
"quirk(motion): claimed address {addr} as the H5000 Motion Sensor \
(PGN 126996 product code 0x53F0); announcing product info, waiting \
for a Furuno 130842 source to transcode into 130824 rates"
);
out.extend(self.product_info());
}
super::stamp(&mut out);
out
}
fn handle_request(&self, d: &DecodedPgn) -> Vec<RawFrame> {
let Some(requested) = d
.field(field::iso_request::PGN)
.and_then(|f| f.value.as_i64())
else {
return Vec::new();
};
let requested = requested as u32;
let addressed = self.claim.address() == Some(d.dst) || d.dst == ADDR_GLOBAL;
if !addressed {
return Vec::new();
}
match requested {
PGN_ISO_ADDRESS_CLAIM => self.claim.respond_to_claim_request().into_iter().collect(),
PGN_PRODUCT_INFO => {
log::debug!(
"quirk(motion): answering PGN 126996 request from src {} (the Hercules \
acceptance gate)",
d.src
);
self.product_info().into_iter().collect()
}
PGN_PGN_LIST => {
log::debug!(
"quirk(motion): answering PGN 126464 list request from src {}",
d.src
);
self.pgn_lists()
}
_ => Vec::new(),
}
}
fn maybe_emit_rates(&mut self, d: &DecodedPgn) -> Option<RawFrame> {
let addr = self.claim.send_address()?;
if d.src == addr {
return None; }
if header_manufacturer(&d.data) != Some(MFG_FURUNO) {
return None;
}
let (roll, pitch, yaw) = scx_6dof_rates(&d.data)?;
if self.rate_source.is_none() {
self.rate_source = Some(d.src);
log::info!(
"quirk(motion): transcoding SCX-20 PGN 130842 gyro rates from src {} \
into B&G PGN 130824 (roll/pitch/yaw rate) under addr {addr}",
d.src
);
}
if self.rate_source != Some(d.src) {
return None;
}
Some(RawFrame::new(
None,
PGN_BG_RATES_PRIO,
PGN_BG_RATES,
addr,
ADDR_GLOBAL,
build_130824_payload(roll, pitch, yaw),
))
}
fn product_info(&self) -> Option<RawFrame> {
let addr = self.claim.address()?;
ProductInfo {
db_version: DB_VERSION,
product_code: PRODUCT_CODE,
model_id: MODEL_ID,
software_version: SOFTWARE_VERSION,
model_version: MODEL_VERSION,
model_serial: MODEL_SERIAL,
certification_level: CERTIFICATION_LEVEL,
load_equivalency: LOAD_EQUIVALENCY,
}
.frame(addr)
}
fn pgn_lists(&self) -> Vec<RawFrame> {
match self.claim.address() {
Some(addr) => {
nmea_responder::pgn_list_frames(addr, ADDR_GLOBAL, &TX_PGN_LIST, &RX_PGN_LIST)
}
None => Vec::new(),
}
}
}
fn build_name(state_dir: Option<&std::path::Path>) -> u64 {
let unique = crate::engine::os::unique_number(state_dir);
crate::io::name::Name::new(MFG_BANDG, unique)
.device_function(DEVICE_FUNCTION)
.device_class(DEVICE_CLASS)
.to_u64()
}
fn header_manufacturer(data: &[u8]) -> Option<u16> {
let hdr = u16::from_le_bytes([*data.first()?, *data.get(1)?]);
Some(hdr & 0x07ff)
}
fn scx_6dof_rates(data: &[u8]) -> Option<(i32, i32, i32)> {
let roll = i32::from_le_bytes(data.get(15..19)?.try_into().ok()?);
let pitch = i32::from_le_bytes(data.get(19..23)?.try_into().ok()?);
let yaw = i32::from_le_bytes(data.get(23..27)?.try_into().ok()?);
Some((roll, pitch, yaw))
}
fn transcode_rate(scx_count: i32) -> i32 {
let rad_s = scx_count as f64 * SCX_RATE_LSB_RAD_S;
(rad_s / BG_RATE_LSB_RAD_S)
.round()
.clamp(i32::MIN as f64, i32::MAX as f64) as i32
}
fn build_130824_payload(roll: i32, pitch: i32, yaw: i32) -> Vec<u8> {
let header: u16 = MFG_BANDG | (0b11 << 11) | (INDUSTRY_MARINE << 13);
let mut v = Vec::with_capacity(2 + 3 * 6);
v.extend_from_slice(&header.to_le_bytes());
for (key, count) in [
(KEY_ROLL_RATE, roll),
(KEY_PITCH_RATE, pitch),
(KEY_YAW_RATE, yaw),
] {
let key_len: u16 = (key & 0x0fff) | (4 << 12); v.extend_from_slice(&key_len.to_le_bytes());
v.extend_from_slice(&transcode_rate(count).to_le_bytes());
}
v
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::{PgnDatabase, Units};
use std::time::Duration;
fn si() -> &'static PgnDatabase {
PgnDatabase::embedded(Units::Si)
}
fn dec(f: &RawFrame) -> DecodedPgn {
si().decode(f).expect("decodes")
}
fn request(src: u8, dst: u8, target: u32) -> DecodedPgn {
let data = [target as u8, (target >> 8) as u8, (target >> 16) as u8];
dec(&RawFrame::new(None, 6, PGN_ISO_REQUEST, src, dst, data))
}
fn attitude(src: u8) -> DecodedPgn {
let data = [0x00, 0xff, 0x7f, 0xcd, 0x00, 0x01, 0xfc, 0xff];
dec(&RawFrame::new(None, 3, 127257, src, ADDR_GLOBAL, data))
}
fn six_dof(src: u8) -> DecodedPgn {
let data = [
0x3f, 0x9f, 0x54, 0xff, 0xff, 0xff, 0xd4, 0xff, 0xff, 0xff, 0x2d, 0x03, 0x00, 0x00,
0x00, 0x78, 0xf7, 0xff, 0xff, 0xbb, 0x05, 0x00, 0x00, 0x06, 0x03, 0x00, 0x00, 0x00,
0x00,
];
dec(&RawFrame::new(
None,
7,
PGN_SCX_6DOF,
src,
ADDR_GLOBAL,
data,
))
}
fn drive_to_claimed(m: &mut Motion, t0: Instant) -> u8 {
m.process(&attitude(200), t0);
m.process(&attitude(200), t0 + Duration::from_millis(1001));
m.process(&attitude(200), t0 + Duration::from_millis(1300));
m.claim.address().expect("claimed")
}
#[test]
fn claims_preferred_address_on_idle_bus() {
let mut m = Motion::new(None);
let addr = drive_to_claimed(&mut m, Instant::now());
assert_eq!(addr, PREFERRED_ADDRESS);
}
#[test]
fn answers_product_info_with_the_gate_code() {
let mut m = Motion::new(None);
let t0 = Instant::now();
let addr = drive_to_claimed(&mut m, t0);
let out = m.process(
&request(9, addr, PGN_PRODUCT_INFO),
t0 + Duration::from_millis(1400),
);
let pi = out
.iter()
.find(|f| f.pgn == PGN_PRODUCT_INFO)
.expect("product info emitted");
assert_eq!(pi.src, addr);
let d = si().decode(pi).unwrap();
assert_eq!(
d.field(field::product_information::PRODUCT_CODE)
.and_then(|f| f.value.as_i64()),
Some(PRODUCT_CODE),
"the Hercules acceptance gate"
);
assert_eq!(
d.field(field::product_information::MODEL_ID)
.and_then(|f| f.value.as_str()),
Some(MODEL_ID)
);
}
#[test]
fn transcode_rate_scales_and_saturates() {
let gain = SCX_RATE_LSB_RAD_S / BG_RATE_LSB_RAD_S;
assert_eq!(transcode_rate(1000), (1000.0 * gain).round() as i32);
assert_eq!(transcode_rate(-1000), -(1000.0 * gain).round() as i32);
assert!(
gain > 100.0,
"sanity: a mid-hundreds gain, never the ×10 trap"
);
assert_eq!(transcode_rate(0), 0);
assert_eq!(transcode_rate(i32::MAX), i32::MAX);
assert_eq!(transcode_rate(i32::MIN), i32::MIN);
}
#[test]
fn builds_130824_with_the_three_rate_keys() {
let p = build_130824_payload(-2184, 1467, 774);
assert_eq!(p.len(), 20);
assert_eq!(&p[0..2], &[0x7d, 0x99], "B&G mfr 381 + marine header");
let entry = |off: usize| -> (u16, u8, i32) {
let kl = u16::from_le_bytes([p[off], p[off + 1]]);
let val = i32::from_le_bytes(p[off + 2..off + 6].try_into().unwrap());
(kl & 0x0fff, (kl >> 12) as u8, val)
};
assert_eq!(entry(2), (KEY_ROLL_RATE, 4, transcode_rate(-2184)));
assert_eq!(entry(8), (KEY_PITCH_RATE, 4, transcode_rate(1467)));
assert_eq!(entry(14), (KEY_YAW_RATE, 4, transcode_rate(774)));
}
#[test]
fn transcodes_furuno_6dof_into_bg_rates() {
let mut m = Motion::new(None);
let t0 = Instant::now();
let addr = drive_to_claimed(&mut m, t0);
let out = m.process(&six_dof(52), t0 + Duration::from_millis(1400));
let f = out
.iter()
.find(|f| f.pgn == PGN_BG_RATES)
.expect("130824 emitted");
assert_eq!(f.src, addr);
assert_eq!(f.prio, PGN_BG_RATES_PRIO);
assert_eq!(f.data.as_slice(), build_130824_payload(-2184, 1467, 774));
let out = m.process(&six_dof(60), t0 + Duration::from_millis(1500));
assert!(out.iter().all(|f| f.pgn != PGN_BG_RATES));
}
#[test]
fn ignores_non_furuno_6dof() {
assert_eq!(header_manufacturer(&[0x3f, 0x9f]), Some(MFG_FURUNO));
assert_eq!(header_manufacturer(&[0x7d, 0x99]), Some(MFG_BANDG));
assert_eq!(header_manufacturer(&[0x00]), None);
}
#[test]
fn answers_address_claim_request() {
let mut m = Motion::new(None);
let t0 = Instant::now();
let addr = drive_to_claimed(&mut m, t0);
let out = m.process(
&request(9, ADDR_GLOBAL, PGN_ISO_ADDRESS_CLAIM),
t0 + Duration::from_millis(1400),
);
let c = out
.iter()
.find(|f| f.pgn == PGN_ISO_ADDRESS_CLAIM)
.expect("claim answered");
assert_eq!(c.src, addr);
}
}