imu: the WT61 on any USB-UART adapter, in the car's frame, on yukart/imu/*
/ crosscheck (push) Successful in 0s
Rust (L4T) / Clippy, test, build, glibc (push) Successful in 14s

Finds the module itself (every non-modem adapter, 115200 then 9600), so the
adapter can be swapped. Mounting by --forward/--right; attitude from a
complementary filter on gravity (gyro through motion, accelerometer near 1 g);
gravity removed from acceleration; yaw rate left-positive. Never writes to
the module. dialout via SupplementaryGroups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DDwfd6Ki8UfG4hK6W5YpJz
This commit is contained in:
2026-10-14 00:23:52 +09:00
parent b906570327
commit acb47b48ef
8 changed files with 793 additions and 2 deletions
+5 -1
View File
@@ -2,7 +2,7 @@
name = "svc-core"
version = "0.1.0"
edition = "2021"
description = "Acquisition and processing cluster: recorder, decoder, gnss, orin-status and obd-poller now; can-bridge later"
description = "Acquisition and processing cluster: recorder, decoder, gnss, imu, orin-status and obd-poller now; can-bridge later"
publish = false
[[bin]]
@@ -25,6 +25,10 @@ path = "src/decoder/main.rs"
name = "gnss"
path = "src/gnss/main.rs"
[[bin]]
name = "imu"
path = "src/imu/main.rs"
[[bin]]
name = "bus-bench"
path = "src/bus-bench/main.rs"
+3 -1
View File
@@ -10,7 +10,7 @@ This file serves as the minimalist portal to other docs
## What this repo is about
The acquisition and processing cluster on the Orin, developed and deployed as one
unit: `recorder`, `decoder`, `gnss`, `orin-status` and `obd-poller` now; `can-bridge` as it is
unit: `recorder`, `decoder`, `gnss`, `imu`, `orin-status` and `obd-poller` now; `can-bridge` as it is
needed.
| Binary | Installed as | Does |
@@ -20,6 +20,7 @@ needed.
| `bus-bench` | not installed: a test tool | Raw CAN and decoded-signal load over MQTT, measured: loss, latency, broker CPU ([report](docs/impl/reports/BUS-BENCH.md)) |
| `obd-poller` | `yukart-obd-poller.service` | OBD-II mode 01 on the K-line through the Bluetooth ELM327 (address in `/etc/default/yukart-elm`), the single writer to the car; never on CAN. The `obd_*` signals, calibration references (`yukart/vehicle/*`): rpm, speed, throttle, load, MAF, timing, O2 every round; temperatures and trims every 5 s. Lets go of the adapter after a minute of ECU silence |
| `gnss` | `yukart-gnss.service` | gpsd's fix as single values on `yukart/gnss/*`: fix, speed, heading, position, altitude, satellites |
| `imu` | `yukart-imu.service` | The WT61 (plus) on any USB-UART adapter, found by itself (adapter and speed), turned into the car's frame (`--forward`, `--right`): roll, pitch, gravity-free longitudinal and lateral g, yaw rate on `yukart/imu/*`, at most every 40 ms. Never writes to the module |
| `orin-status` | `yukart-orin-status.service` | The Orin's health on the bus, once a second: temperatures, CPU/GPU load, memory, power, fan, WiFi (with SSID)/LTE/tablet links, the CAN interface, the ELM's Bluetooth link, UPS battery (`yukart/host/*`) |
```bash
@@ -32,6 +33,7 @@ zstdcat can-*.log.zst | yukart-decoder --replay # what it would publish for a
yukart-obd-poller --elm ADDR --once # on the Orin, service stopped: adapter, protocol,
# supported PIDs and one value each
yukart-gnss --once # the next fix as JSON, no broker
yukart-imu --once # find the WT61, 3 s of readings as JSON, no broker
yukart-orin-status --once # on an Orin: one reading as JSON, no broker
yukart-recorder ctl status # on the Orin
yukart-recorder ctl start "cold start" # manual session, ends only on stop
+19
View File
@@ -0,0 +1,19 @@
[Unit]
Description=Yukart imu: the WT61 on a USB-UART adapter, as the car's motion on the bus
Documentation=gitea:yukart/svc-core
# Tier 2. Ordered after the broker and nothing else: it finds the adapter by itself,
# whenever it appears.
After=mosquitto.service
StartLimitIntervalSec=0
[Service]
User=mikkeli
# USB serial adapters belong to dialout: the one right it needs, no sudo.
SupplementaryGroups=dialout
# Mounting: add --forward AXIS --right AXIS once the module has its place in the car.
ExecStart=/usr/local/bin/yukart-imu
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# Build imu natively on the development Orin and install it on the car Orin as
# yukart-imu: scripts/deploy-native.sh does the work.
#
# usage: scripts/deploy-imu.sh [host] (default: mikkeli-orin-nano)
exec "$(dirname "$0")/deploy-native.sh" imu "$@"
+220
View File
@@ -0,0 +1,220 @@
//! From the module's axes to the car's, and from raw motion to what contracts' `imu_*`
//! signals mean: roll, pitch, gravity-free acceleration and yaw rate.
//!
//! Car frame: x forward, y right, z down (right-handed). The mounting says which module
//! axis points forward and which points right; down follows. Attitude comes from a
//! complementary filter on the gravity direction: the gyro carries it through motion,
//! the accelerometer pulls it back whenever the total force is close to 1 g. The
//! module's own angles are not used: they assume the module's own mounting.
/// A signed module axis: 0 = X, 1 = Y, 2 = Z.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Axis {
index: usize,
sign: f64,
}
impl Axis {
/// "+x", "-z", "y" (plus implied).
pub fn parse(s: &str) -> Option<Axis> {
let s = s.trim().to_ascii_lowercase();
let (sign, rest) = match s.strip_prefix('-') {
Some(r) => (-1.0, r),
None => (1.0, s.strip_prefix('+').unwrap_or(&s)),
};
let index = match rest {
"x" => 0,
"y" => 1,
"z" => 2,
_ => return None,
};
Some(Axis { index, sign })
}
fn unit(self) -> [f64; 3] {
let mut u = [0.0; 3];
u[self.index] = self.sign;
u
}
}
fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
}
fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
fn norm(a: [f64; 3]) -> f64 {
dot(a, a).sqrt()
}
/// Module axes → car axes: rows forward, right, down (= forward × right).
#[derive(Debug, Clone, Copy)]
pub struct Mount([[f64; 3]; 3]);
impl Mount {
/// None when the two axes are the same line.
pub fn new(forward: Axis, right: Axis) -> Option<Mount> {
if forward.index == right.index {
return None;
}
let (f, r) = (forward.unit(), right.unit());
Some(Mount([f, r, cross(f, r)]))
}
pub fn to_car(self, v: [f64; 3]) -> [f64; 3] {
[dot(self.0[0], v), dot(self.0[1], v), dot(self.0[2], v)]
}
}
/// One output: the `imu_*` signals.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Motion {
pub roll_deg: f64,
pub pitch_deg: f64,
pub accel_lon_g: f64,
pub accel_lat_g: f64,
pub yaw_rate_dps: f64,
}
pub struct Attitude {
mount: Mount,
/// Gravity's direction in the car frame, unit length, pointing down. None until the first sample.
down: Option<[f64; 3]>,
/// Latest angular rate, car frame, rad/s.
rate: [f64; 3],
/// Seconds for the accelerometer to pull a disturbed attitude most of the way back.
tau: f64,
}
impl Attitude {
pub fn new(mount: Mount) -> Self {
Attitude { mount, down: None, rate: [0.0; 3], tau: 2.0 }
}
pub fn gyro(&mut self, dps: [f64; 3]) {
self.rate = self.mount.to_car(dps).map(f64::to_radians);
}
/// One accelerometer sample (specific force, g, module axes), `dt` seconds after the last.
pub fn accel(&mut self, g: [f64; 3], dt: f64) -> Option<Motion> {
let f = self.mount.to_car(g);
let n = norm(f);
if n < 1e-6 {
return None;
}
// At rest the accelerometer reads 1 g upwards, so gravity is the opposite of f.
let measured = f.map(|x| -x / n);
let mut d = match self.down {
None => measured,
Some(d) => {
// A world-fixed vector seen from a rotating body turns the other way: dd/dt = -ω × d.
let w = cross(self.rate, d);
let mut p = [d[0] - w[0] * dt, d[1] - w[1] * dt, d[2] - w[2] * dt];
let pn = norm(p);
p = p.map(|x| x / pn);
if (n - 1.0).abs() < 0.15 {
let k = (dt / self.tau).clamp(0.0, 1.0);
p = [0, 1, 2].map(|i| p[i] * (1.0 - k) + measured[i] * k);
let pn = norm(p);
p = p.map(|x| x / pn);
}
p
}
};
if d.iter().any(|x| !x.is_finite()) {
d = measured;
}
self.down = Some(d);
// Kinematic acceleration = specific force + gravity (1 g along `down`).
let a = [f[0] + d[0], f[1] + d[1], f[2] + d[2]];
Some(Motion {
// Right side down: gravity leans onto +y. Nose up: gravity leans onto -x.
roll_deg: d[1].atan2(d[2]).to_degrees(),
pitch_deg: (-d[0]).atan2((d[1] * d[1] + d[2] * d[2]).sqrt()).to_degrees(),
accel_lon_g: a[0],
accel_lat_g: a[1],
// About the down axis, positive is clockwise from above; contracts count a left turn positive.
yaw_rate_dps: -self.rate[2].to_degrees(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The datasheet's axes, module flat and face up: X right, Y forward, Z up.
fn flat() -> Mount {
Mount::new(Axis::parse("+y").unwrap(), Axis::parse("+x").unwrap()).unwrap()
}
fn close(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-6
}
#[test]
fn axes() {
assert_eq!(Axis::parse("-Z"), Some(Axis { index: 2, sign: -1.0 }));
assert_eq!(Axis::parse("x"), Some(Axis { index: 0, sign: 1.0 }));
assert_eq!(Axis::parse("w"), None);
assert!(Mount::new(Axis::parse("+x").unwrap(), Axis::parse("-x").unwrap()).is_none());
// Flat: module up (+z) is car up, so car down is module -z.
assert_eq!(flat().to_car([0.0, 0.0, 1.0]), [0.0, 0.0, -1.0]);
}
#[test]
fn level_and_still_reads_zero() {
let mut a = Attitude::new(flat());
let m = a.accel([0.0, 0.0, 1.0], 0.01).unwrap();
assert!(close(m.roll_deg, 0.0) && close(m.pitch_deg, 0.0));
assert!(close(m.accel_lon_g, 0.0) && close(m.accel_lat_g, 0.0));
}
#[test]
fn tilts_have_the_contract_signs() {
// Nose up θ: in the car frame gravity is (-sin θ, 0, cos θ) and the accelerometer reads
// the opposite. The flat mount's inverse gives the module's reading.
let t = 10f64.to_radians();
let f_car = [t.sin(), 0.0, -t.cos()];
let module = [f_car[1], f_car[0], -f_car[2]]; // x = right, y = forward, z = up
let m = Attitude::new(flat()).accel(module, 0.01).unwrap();
assert!((m.pitch_deg - 10.0).abs() < 1e-6, "{m:?}");
// Right side down 5°: down leans onto +y (right).
let r = 5f64.to_radians();
let f_car = [0.0, -r.sin(), -r.cos()];
let module = [f_car[1], f_car[0], -f_car[2]];
let m = Attitude::new(flat()).accel(module, 0.01).unwrap();
assert!((m.roll_deg - 5.0).abs() < 1e-6, "{m:?}");
}
#[test]
fn acceleration_and_yaw_signs() {
let mut a = Attitude::new(flat());
a.accel([0.0, 0.0, 1.0], 0.01);
// Speeding up at 0.2 g: the force pushes forward (module +Y). A right turn's force pushes right (+X).
let m = a.accel([0.1, 0.2, 1.0], 0.01).unwrap();
assert!((m.accel_lon_g - 0.2).abs() < 0.01 && (m.accel_lat_g - 0.1).abs() < 0.01, "{m:?}");
// Turning left: counter-clockwise from above, about module +Z (up) by the right-hand rule.
a.gyro([0.0, 0.0, 30.0]);
let m = a.accel([0.0, 0.0, 1.0], 0.01).unwrap();
assert!(close(m.yaw_rate_dps, 30.0), "{m:?}");
}
#[test]
fn gyro_carries_a_roll_the_accelerometer_has_not_seen() {
let mut a = Attitude::new(flat());
a.accel([0.0, 0.0, 1.0], 0.01);
// Rolling right at 20°/s for 1 s: about the forward axis (module +Y), right side down.
a.gyro([0.0, 20.0, 0.0]);
let mut m = None;
for _ in 0..100 {
m = a.accel([0.0, 0.0, 1.0], 0.01); // accelerometer still says level: it disagrees
}
let roll = m.unwrap().roll_deg;
// The gyro's 20° wins most of it back over the 2 s accelerometer pull.
assert!(roll > 10.0 && roll < 20.0, "{roll}");
}
}
+324
View File
@@ -0,0 +1,324 @@
//! imu — the WT61 (plus) on a USB-UART adapter, as the car's motion on the bus.
//!
//! Finds the module by itself: every USB-UART adapter that is not the modem, at
//! 115200 then 9600 baud, until one sends WT61 packets; no device name is fixed, so
//! the adapter can be swapped. Turns acceleration and angular rate into the car's
//! frame (`--forward` and `--right` name the module axes that point that way) and
//! publishes contracts' `imu_*` signals on `yukart/imu/<signal>`, retained, at most
//! every 40 ms: roll, pitch, gravity-free longitudinal and lateral g, yaw rate.
//! Names and units from `signals.json` (source `imu`).
//!
//! Standalone: it needs the broker and nothing else, and never writes to the module
//! (its rate and speed are set once, by hand: orin-nano-env-setup `docs/BOM.md`).
//! State on `yukart/system/imu`, with a last will.
mod frame;
mod serial;
mod wt61;
use std::collections::HashMap;
use std::process::ExitCode;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};
use rumqttc::{Client, Event, LastWill, MqttOptions, Packet as MqttPacket, QoS};
use serde_json::{json, Value};
use frame::{Attitude, Axis, Motion, Mount};
use wt61::{Packet, Parser};
const USAGE: &str = "\
usage: imu [options]
--broker HOST:PORT MQTT broker (127.0.0.1:1883)
--device PATH the adapter, instead of trying each (any)
--baud N its speed, instead of trying 115200 and 9600 (any)
--forward AXIS module axis pointing forward: +x, -y, ... (+y)
--right AXIS module axis pointing right (+x)
--every-ms N publish at most this often (40)
--once find the module, print 3 s of readings as JSON, exit (no broker)
The defaults are the datasheet's drawing: module flat, face up, Y forward, X right.";
const REGISTRY: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../contracts/schema/signals.json"));
const SYSTEM_TOPIC: &str = "yukart/system/imu";
fn now_us() -> i64 {
let d = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
d.as_micros() as i64
}
fn mono_ns() -> i64 {
let mut ts: libc::timespec = unsafe { std::mem::zeroed() };
unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut ts) };
ts.tv_sec as i64 * 1_000_000_000 + ts.tv_nsec as i64
}
/// Units of the imu signals, from the registry: never authored here (contracts guideline 1).
fn units() -> HashMap<String, String> {
let v: Value = serde_json::from_str(REGISTRY).expect("contracts signals.json is valid JSON");
v["signals"]
.as_object()
.expect("signals.json has a signals object")
.iter()
.filter(|(_, s)| s["source"] == "imu")
.map(|(k, s)| (k.clone(), s["unit"].as_str().unwrap_or("").to_string()))
.collect()
}
fn signals(m: &Motion) -> [(&'static str, Value); 5] {
let r = |v: f64, places: i32| {
let f = 10f64.powi(places);
json!((v * f).round() / f)
};
[
("imu_roll_deg", r(m.roll_deg, 1)),
("imu_pitch_deg", r(m.pitch_deg, 1)),
("imu_accel_lon_g", r(m.accel_lon_g, 3)),
("imu_accel_lat_g", r(m.accel_lat_g, 3)),
("imu_yaw_rate_dps", r(m.yaw_rate_dps, 1)),
]
}
struct Opts {
broker: String,
device: Option<String>,
baud: Option<u32>,
mount: Mount,
every: Duration,
once: bool,
}
fn parse() -> Result<Opts, String> {
let (mut forward, mut right) = (Axis::parse("+y").unwrap(), Axis::parse("+x").unwrap());
let mut o = Opts {
broker: "127.0.0.1:1883".into(),
device: None,
baud: None,
mount: Mount::new(forward, right).unwrap(),
every: Duration::from_millis(40),
once: false,
};
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
if a == "--once" {
o.once = true;
continue;
}
let v = args.next().ok_or(format!("{a} needs a value"))?;
let axis = || Axis::parse(&v).ok_or(format!("{a}: not an axis: {v} (+x, -y, z, ...)"));
match a.as_str() {
"--broker" => o.broker = v.clone(),
"--device" => o.device = Some(v.clone()),
"--baud" => o.baud = Some(v.parse().map_err(|_| format!("--baud: not a number: {v}"))?),
"--forward" => forward = axis()?,
"--right" => right = axis()?,
"--every-ms" => o.every = Duration::from_millis(v.parse().map_err(|_| format!("--every-ms: not a number: {v}"))?),
_ => return Err(format!("unknown option {a}")),
}
}
o.mount = Mount::new(forward, right).ok_or("--forward and --right must be different axes")?;
Ok(o)
}
/// Reads one port until it fails or goes quiet; hands each new attitude to `out`, and
/// packet counts to `stats`. Returns why it stopped.
fn run(port: &serial::Port, mount: Mount, stop: &dyn Fn() -> bool, mut out: impl FnMut(Motion, &Counts)) -> String {
let (mut parser, mut packets, mut buf) = (Parser::default(), Vec::new(), [0u8; 1024]);
let mut att = Attitude::new(mount);
let mut counts = Counts::default();
let mut last_chunk = Instant::now();
let mut heard = Instant::now();
while !stop() {
let n = match port.read(&mut buf) {
Ok(n) => n,
Err(e) => return format!("read: {e}"),
};
if n == 0 {
if heard.elapsed() > Duration::from_secs(3) {
return "no packets for 3 s".into();
}
continue;
}
parser.push(&buf[..n], &mut packets);
counts.bad = parser.bad;
// The adapter delivers in chunks: spread the chunk's time over its accelerometer packets.
let accels = packets.iter().filter(|p| matches!(p, Packet::Accel(_))).count().max(1);
let dt = (last_chunk.elapsed().as_secs_f64() / accels as f64).clamp(0.001, 0.1);
last_chunk = Instant::now();
for p in packets.drain(..) {
heard = Instant::now();
match p {
Packet::Gyro(g) => {
counts.gyro += 1;
att.gyro(g);
}
Packet::Accel(a) => {
counts.accel += 1;
if let Some(m) = att.accel(a, dt) {
out(m, &counts);
}
}
Packet::Angle(_) => counts.angle += 1,
}
}
}
"stopped".into()
}
#[derive(Default, Clone, Copy)]
struct Counts {
accel: u64,
gyro: u64,
angle: u64,
bad: u64,
}
fn once(o: &Opts) -> ExitCode {
let Some((path, baud)) = serial::find(o.device.as_deref(), o.baud) else {
eprintln!("imu: no WT61 on {:?} at {:?}", o.device.clone().map(|d| vec![d]).unwrap_or_else(serial::adapters), serial::BAUDS);
return ExitCode::FAILURE;
};
let port = match serial::Port::open(&path, baud) {
Ok(p) => p,
Err(e) => {
eprintln!("imu: {path}: {e}");
return ExitCode::FAILURE;
}
};
let t = Instant::now();
let mut last = None;
let mut counts = Counts::default();
run(&port, o.mount, &|| t.elapsed() > Duration::from_secs(3), |m, c| {
last = Some(m);
counts = *c;
});
let secs = t.elapsed().as_secs_f64();
println!(
"{}",
serde_json::to_string_pretty(&json!({
"device": path, "baud": baud,
"rate_hz": { "accel": (counts.accel as f64 / secs).round(), "gyro": (counts.gyro as f64 / secs).round(), "angle": (counts.angle as f64 / secs).round() },
"bad_checksums": counts.bad,
"signals": last.map(|m| signals(&m).into_iter().map(|(k, v)| (k.to_string(), v)).collect::<serde_json::Map<_, _>>()),
}))
.unwrap()
);
ExitCode::SUCCESS
}
fn main() -> ExitCode {
let o = match parse() {
Ok(o) => o,
Err(e) => {
eprintln!("imu: {e}\n{USAGE}");
return ExitCode::from(2);
}
};
if o.once {
return once(&o);
}
let units = units();
let Some((host, port)) = o.broker.rsplit_once(':').and_then(|(h, p)| Some((h.to_string(), p.parse::<u16>().ok()?)))
else {
eprintln!("imu: --broker must be HOST:PORT\n{USAGE}");
return ExitCode::from(2);
};
let mut opts = MqttOptions::new("imu", host, port);
opts.set_keep_alive(Duration::from_secs(5));
opts.set_last_will(LastWill::new(
SYSTEM_TOPIC,
json!({ "state": "stopping", "detail": "connection lost" }).to_string(),
QoS::AtLeastOnce,
true,
));
let (client, mut connection) = Client::new(opts, 512);
let connected = Arc::new(AtomicBool::new(false));
{
let connected = connected.clone();
thread::spawn(move || {
for ev in connection.iter() {
match ev {
Ok(Event::Incoming(MqttPacket::ConnAck(_))) => {
eprintln!("imu: connected to the broker");
connected.store(true, Ordering::Relaxed);
}
Ok(_) => {}
Err(e) => {
if connected.swap(false, Ordering::Relaxed) {
eprintln!("imu: broker: {e}; retrying");
}
thread::sleep(Duration::from_secs(1));
}
}
}
});
}
let state = |s: Value| {
if connected.load(Ordering::Relaxed) {
let _ = client.try_publish(SYSTEM_TOPIC, QoS::AtLeastOnce, true, s.to_string());
}
};
eprintln!("imu {} publishing to {}", env!("CARGO_PKG_VERSION"), o.broker);
let mut last_detail = String::new();
loop {
let Some((path, baud)) = serial::find(o.device.as_deref(), o.baud) else {
let d = format!("no WT61 on {:?}", o.device.clone().map(|d| vec![d]).unwrap_or_else(serial::adapters));
if d != last_detail {
eprintln!("imu: {d}; retrying");
last_detail = d.clone();
}
thread::sleep(Duration::from_millis(500));
state(json!({ "state": "degraded", "detail": d }));
thread::sleep(Duration::from_secs(2));
continue;
};
let port = match serial::Port::open(&path, baud) {
Ok(p) => p,
Err(e) => {
eprintln!("imu: {path}: {e}");
thread::sleep(Duration::from_secs(2));
continue;
}
};
eprintln!("imu: WT61 on {path} at {baud} baud");
last_detail.clear();
let mut last_pub = Instant::now() - o.every;
let mut window = (Instant::now(), Counts::default());
let why = run(&port, o.mount, &|| false, |m, c| {
if window.0.elapsed() >= Duration::from_secs(5) {
let secs = window.0.elapsed().as_secs_f64();
state(json!({ "state": "ready", "detail": {
"device": path, "baud": baud,
"rate_hz": ((c.accel - window.1.accel) as f64 / secs).round(),
"bad_checksums": c.bad } }));
window = (Instant::now(), *c);
}
if last_pub.elapsed() < o.every || !connected.load(Ordering::Relaxed) {
return;
}
last_pub = Instant::now();
for (name, value) in signals(&m) {
let unit = units.get(name).unwrap_or_else(|| panic!("signal {name} is not an imu signal in contracts signals.json"));
let env = json!({ "timestamp": now_us(), "monotonic": mono_ns(), "value": value, "unit": unit });
let _ = client.try_publish(format!("yukart/imu/{name}"), QoS::AtMostOnce, true, env.to_string());
}
});
eprintln!("imu: {path}: {why}; looking again");
state(json!({ "state": "degraded", "detail": format!("{path}: {why}") }));
}
}
#[cfg(test)]
mod tests {
#[test]
fn every_signal_is_registered() {
let u = super::units();
let m = super::Motion { roll_deg: 0.0, pitch_deg: 0.0, accel_lon_g: 0.0, accel_lat_g: 0.0, yaw_rate_dps: 0.0 };
for (k, _) in super::signals(&m) {
assert!(u.contains_key(k), "{k} not an imu signal in signals.json");
}
}
}
+124
View File
@@ -0,0 +1,124 @@
//! A raw serial port (termios), and finding the WT61 among the USB serial adapters.
//! No device name is fixed: the adapter may change (CP2102 now, maybe a PL2303 later),
//! so every adapter that is not the modem is tried at each speed until one sends
//! WT61 packets.
use std::ffi::CString;
use std::io;
use std::time::{Duration, Instant};
use crate::wt61::{Packet, Parser};
/// Drivers of plain USB-UART adapters. The SIM7600's ports (`option`) are never opened:
/// they belong to ModemManager.
const ADAPTER_DRIVERS: [&str; 4] = ["cp210x", "pl2303", "ftdi_sio", "ch341"];
/// Fastest first: the module's setting since 2026-10-14, then its other speed.
pub const BAUDS: [u32; 2] = [115200, 9600];
pub struct Port {
fd: i32,
}
impl Drop for Port {
fn drop(&mut self) {
unsafe { libc::close(self.fd) };
}
}
fn speed(baud: u32) -> Option<libc::speed_t> {
Some(match baud {
9600 => libc::B9600,
19200 => libc::B19200,
38400 => libc::B38400,
57600 => libc::B57600,
115200 => libc::B115200,
230400 => libc::B230400,
_ => return None,
})
}
impl Port {
/// Raw 8N1, reads returning after at most 0.2 s.
pub fn open(path: &str, baud: u32) -> io::Result<Self> {
let sp = speed(baud).ok_or_else(|| io::Error::other(format!("unsupported speed {baud}")))?;
let c = CString::new(path).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
let fd = unsafe { libc::open(c.as_ptr(), libc::O_RDWR | libc::O_NOCTTY | libc::O_CLOEXEC) };
if fd < 0 {
return Err(io::Error::last_os_error());
}
let port = Port { fd };
unsafe {
let mut t: libc::termios = std::mem::zeroed();
if libc::tcgetattr(fd, &mut t) < 0 {
return Err(io::Error::last_os_error());
}
libc::cfmakeraw(&mut t);
t.c_cflag |= libc::CLOCAL | libc::CREAD;
t.c_cflag &= !(libc::CSTOPB | libc::PARENB | libc::CRTSCTS);
t.c_cc[libc::VMIN] = 0;
t.c_cc[libc::VTIME] = 2;
libc::cfsetispeed(&mut t, sp);
libc::cfsetospeed(&mut t, sp);
if libc::tcsetattr(fd, libc::TCSANOW, &t) < 0 {
return Err(io::Error::last_os_error());
}
libc::tcflush(fd, libc::TCIFLUSH);
}
Ok(port)
}
/// Whatever arrived; empty after 0.2 s of silence. An error means the adapter went away.
pub fn read(&self, buf: &mut [u8]) -> io::Result<usize> {
let n = unsafe { libc::read(self.fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
if n < 0 { Err(io::Error::last_os_error()) } else { Ok(n as usize) }
}
}
/// `/dev/ttyUSBn` of every USB-UART adapter present, in name order.
pub fn adapters() -> Vec<String> {
let mut v: Vec<String> = std::fs::read_dir("/sys/bus/usb-serial/devices")
.into_iter()
.flatten()
.flatten()
.filter(|e| {
std::fs::read_link(e.path().join("driver"))
.ok()
.and_then(|d| d.file_name().map(|n| ADAPTER_DRIVERS.contains(&n.to_string_lossy().as_ref())))
.unwrap_or(false)
})
.map(|e| format!("/dev/{}", e.file_name().to_string_lossy()))
.collect();
v.sort();
v
}
/// Does `path` at `baud` carry WT61 packets? Listens up to `within`.
pub fn is_wt61(path: &str, baud: u32, within: Duration) -> bool {
let Ok(port) = Port::open(path, baud) else { return false };
let (mut parser, mut out, mut buf) = (Parser::default(), Vec::<Packet>::new(), [0u8; 512]);
let t = Instant::now();
while t.elapsed() < within {
match port.read(&mut buf) {
Ok(n) => parser.push(&buf[..n], &mut out),
Err(_) => return false,
}
if out.len() >= 3 {
return true;
}
}
false
}
/// The first (adapter, speed) carrying WT61 packets, or a fixed device tried at each speed.
pub fn find(device: Option<&str>, baud: Option<u32>) -> Option<(String, u32)> {
let paths = device.map(|d| vec![d.to_string()]).unwrap_or_else(adapters);
let bauds: Vec<u32> = baud.map(|b| vec![b]).unwrap_or_else(|| BAUDS.to_vec());
for p in &paths {
for &b in &bauds {
if is_wt61(p, b, Duration::from_millis(1500)) {
return Some((p.clone(), b));
}
}
}
None
}
+92
View File
@@ -0,0 +1,92 @@
//! The WT61's serial stream (the plain WT61 and the WT61 plus alike): 11-byte packets,
//! `0x55`, a type, three little-endian int16 values and a temperature, then a checksum
//! (the low byte of the sum of the first ten). Types: 0x51 acceleration, 0x52 angular
//! rate, 0x53 angle (the module's own fusion, in its own frame; not used for the car).
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Packet {
/// Specific force, g, module axes.
Accel([f64; 3]),
/// Angular rate, degree/s, module axes.
Gyro([f64; 3]),
/// Roll, pitch, yaw in degrees, as the module computes them.
Angle([f64; 3]),
}
fn values(p: &[u8]) -> [f64; 3] {
let v = |i: usize| i16::from_le_bytes([p[2 + 2 * i], p[3 + 2 * i]]) as f64 / 32768.0;
[v(0), v(1), v(2)]
}
/// Splits a byte stream into packets, resynchronising after noise or a bad checksum.
#[derive(Default)]
pub struct Parser {
buf: Vec<u8>,
/// Packets dropped for a bad checksum.
pub bad: u64,
}
impl Parser {
pub fn push(&mut self, bytes: &[u8], out: &mut Vec<Packet>) {
self.buf.extend_from_slice(bytes);
let mut i = 0;
while i + 11 <= self.buf.len() {
let p = &self.buf[i..i + 11];
if p[0] != 0x55 || !(0x51..=0x53).contains(&p[1]) {
i += 1;
continue;
}
if p[..10].iter().fold(0u8, |s, b| s.wrapping_add(*b)) != p[10] {
self.bad += 1;
i += 1;
continue;
}
let v = values(p);
out.push(match p[1] {
0x51 => Packet::Accel(v.map(|x| x * 16.0)),
0x52 => Packet::Gyro(v.map(|x| x * 2000.0)),
_ => Packet::Angle(v.map(|x| x * 180.0)),
});
i += 11;
}
self.buf.drain(..i);
if self.buf.len() > 4096 {
self.buf.clear(); // nothing parsed in 4 KB: not a WT61, or the wrong speed
}
}
}
#[cfg(test)]
mod tests {
use super::*;
pub fn packet(kind: u8, v: [i16; 4]) -> Vec<u8> {
let mut p = vec![0x55, kind];
for x in v {
p.extend_from_slice(&x.to_le_bytes());
}
let sum = p.iter().fold(0u8, |s, b| s.wrapping_add(*b));
p.push(sum);
p
}
#[test]
fn parses_resyncs_and_counts_bad_checksums() {
// As read on the car Orin, 2026-10-14: the module on its side, X down.
let mut stream = vec![0x13, 0x55, 0x00]; // noise first
stream.extend(packet(0x51, [-2005, 57, -340, 2985]));
let mut broken = packet(0x52, [1, 2, 3, 0]);
broken[10] ^= 0xFF;
stream.extend(broken);
stream.extend(packet(0x53, [16384, 0, -16384, 0]));
let (mut p, mut out) = (Parser::default(), Vec::new());
// In two pieces, split mid-packet.
p.push(&stream[..20], &mut out);
p.push(&stream[20..], &mut out);
assert_eq!(p.bad, 1);
assert_eq!(out.len(), 2);
let Packet::Accel(a) = out[0] else { panic!("{:?}", out[0]) };
assert!((a[0] + 0.979).abs() < 0.001 && (a[2] + 0.166).abs() < 0.001);
assert_eq!(out[1], Packet::Angle([90.0, 0.0, -90.0]));
}
}