obd-poller: OBD-II mode 01, the single CAN writer; recorder keeps its values
obd-poller asks 0x7DF for the nine obd2 signals, only while the bus is alive, and publishes envelopes stamped with the reply's kernel time. The recorder subscribes to yukart/vehicle/* and writes vehicle.ndjson beside the frames. Tested against a fake ECU on vcan0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DDwfd6Ki8UfG4hK6W5YpJz
This commit is contained in:
+26
-1
@@ -1,7 +1,9 @@
|
||||
//! recorder — persists raw CAN and GNSS to disk, session by session.
|
||||
//!
|
||||
//! Reads SocketCAN and gpsd directly, not through the broker: capture is tier 1 and
|
||||
//! must not depend on anything that can be late or down.
|
||||
//! must not depend on anything that can be late or down (decision 25). Envelopes on
|
||||
//! `yukart/vehicle/*` (OBD-II replies, later decoded signals) come from the broker
|
||||
//! and are recorded when it is there.
|
||||
//!
|
||||
//! Sessions start when CAN frames arrive (auto) or on `ctl start` (manual), and an
|
||||
//! auto session ends after `--idle` seconds without frames: ignition off, adapter
|
||||
@@ -35,6 +37,7 @@ usage:
|
||||
--iface NAME CAN interface (can_pcan)
|
||||
--root DIR recordings directory (/var/lib/yukart/recordings)
|
||||
--gpsd HOST:PORT gpsd, '' to disable (127.0.0.1:2947)
|
||||
--broker HOST:PORT MQTT, for yukart/vehicle/*; '' to disable (127.0.0.1:1883)
|
||||
--ctl PATH control socket (/run/yukart-recorder/ctl.sock)
|
||||
--idle SECS end an auto session after this long without frames (15)
|
||||
--min-secs SECS discard auto sessions shorter than this with no marks (3)
|
||||
@@ -50,6 +53,7 @@ struct Config {
|
||||
iface: String,
|
||||
root: PathBuf,
|
||||
gpsd: String,
|
||||
broker: String,
|
||||
ctl: String,
|
||||
idle: Duration,
|
||||
min_secs: u64,
|
||||
@@ -63,6 +67,7 @@ fn parse_run(args: &[String]) -> Result<Config, String> {
|
||||
iface: "can_pcan".into(),
|
||||
root: "/var/lib/yukart/recordings".into(),
|
||||
gpsd: "127.0.0.1:2947".into(),
|
||||
broker: "127.0.0.1:1883".into(),
|
||||
ctl: "/run/yukart-recorder/ctl.sock".into(),
|
||||
idle: Duration::from_secs(15),
|
||||
min_secs: 3,
|
||||
@@ -82,6 +87,7 @@ fn parse_run(args: &[String]) -> Result<Config, String> {
|
||||
"--iface" => c.iface = v.clone(),
|
||||
"--root" => c.root = v.into(),
|
||||
"--gpsd" => c.gpsd = v.clone(),
|
||||
"--broker" => c.broker = v.clone(),
|
||||
"--ctl" => c.ctl = v.clone(),
|
||||
"--idle" => c.idle = Duration::from_secs(num()?),
|
||||
"--min-secs" => c.min_secs = num()?,
|
||||
@@ -139,6 +145,7 @@ struct Recorder {
|
||||
last_tpv: Option<(f64, String)>,
|
||||
can_link: (bool, String),
|
||||
gnss_link: (bool, String),
|
||||
bus_link: (bool, String),
|
||||
kernel_drops: u64,
|
||||
queue_drops: Arc<AtomicU64>,
|
||||
queue_drops_seen: u64,
|
||||
@@ -156,6 +163,7 @@ impl Recorder {
|
||||
}
|
||||
let _ = s.event("can_link", json!({ "up": self.can_link.0, "detail": self.can_link.1 }));
|
||||
let _ = s.event("gnss_link", json!({ "up": self.gnss_link.0, "detail": self.gnss_link.1 }));
|
||||
let _ = s.event("bus_link", json!({ "up": self.bus_link.0, "detail": self.bus_link.1 }));
|
||||
if let Some((rx, line)) = &self.last_tpv {
|
||||
let _ = s.gnss(*rx, line, true);
|
||||
}
|
||||
@@ -192,6 +200,7 @@ impl Recorder {
|
||||
"can": { "up": self.can_link.0, "detail": self.can_link.1,
|
||||
"last_frame_age_s": self.last_frame.map(|t| t.elapsed().as_secs_f64()) },
|
||||
"gnss": { "up": self.gnss_link.0, "detail": self.gnss_link.1 },
|
||||
"bus": { "up": self.bus_link.0, "detail": self.bus_link.1 },
|
||||
"storage": { "root": self.cfg.root, "used_bytes": storage::usage(&self.cfg.root),
|
||||
"free_bytes": storage::free_bytes(&self.cfg.root),
|
||||
"cap_bytes": self.cfg.bound.cap_bytes },
|
||||
@@ -282,6 +291,18 @@ impl Recorder {
|
||||
let _ = s.event("gnss_link", json!({ "up": up, "detail": detail }));
|
||||
}
|
||||
}
|
||||
Event::Vehicle(rx, signal, payload, retained) => {
|
||||
if let Some(s) = &mut self.session {
|
||||
let _ = s.vehicle(rx, &signal, &payload, retained);
|
||||
}
|
||||
}
|
||||
Event::BusLink(up, detail) => {
|
||||
eprintln!("bus: {detail}");
|
||||
self.bus_link = (up, detail.clone());
|
||||
if let Some(s) = &mut self.session {
|
||||
let _ = s.event("bus_link", json!({ "up": up, "detail": detail }));
|
||||
}
|
||||
}
|
||||
Event::Ctl(cmd, reply) => {
|
||||
let out = self.ctl(&cmd);
|
||||
let _ = reply.send(out);
|
||||
@@ -316,6 +337,9 @@ fn run(cfg: Config) -> ExitCode {
|
||||
if !cfg.gpsd.is_empty() {
|
||||
sources::spawn_gnss(cfg.gpsd.clone(), tx.clone(), stop.clone());
|
||||
}
|
||||
if !cfg.broker.is_empty() {
|
||||
sources::spawn_bus(cfg.broker.clone(), tx.clone(), stop.clone());
|
||||
}
|
||||
if let Some(dir) = Path::new(&cfg.ctl).parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
@@ -336,6 +360,7 @@ fn run(cfg: Config) -> ExitCode {
|
||||
last_tpv: None,
|
||||
can_link: (false, "not yet opened".into()),
|
||||
gnss_link: (false, "not yet connected".into()),
|
||||
bus_link: (false, "not yet connected".into()),
|
||||
kernel_drops: 0,
|
||||
queue_drops,
|
||||
queue_drops_seen: 0,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//! can-0001.log.zst candump -L text, closed chunks compressed
|
||||
//! can-0002.log the open chunk, plain text, fdatasync'd every second
|
||||
//! gnss.ndjson every gpsd report, with the local receive time
|
||||
//! vehicle.ndjson every envelope on yukart/vehicle/* (OBD-II replies now, decoded signals later)
|
||||
//! events.ndjson marks, clock steps, link changes, drops
|
||||
//! ```
|
||||
//!
|
||||
@@ -42,6 +43,7 @@ pub struct Session {
|
||||
chunk_bytes: u64,
|
||||
can: BufWriter<File>,
|
||||
gnss: BufWriter<File>,
|
||||
vehicle: BufWriter<File>,
|
||||
events: BufWriter<File>,
|
||||
line: String,
|
||||
pub frames: u64,
|
||||
@@ -118,6 +120,7 @@ impl Session {
|
||||
let s = Session {
|
||||
can: append(&dir.join(chunk_name(1)))?,
|
||||
gnss: append(&dir.join("gnss.ndjson"))?,
|
||||
vehicle: append(&dir.join("vehicle.ndjson"))?,
|
||||
events: append(&dir.join("events.ndjson"))?,
|
||||
dir, name, trigger, meta,
|
||||
iface: iface.to_string(),
|
||||
@@ -156,6 +159,17 @@ impl Session {
|
||||
writeln!(self.gnss, "{v}")
|
||||
}
|
||||
|
||||
/// One envelope from the bus. `retained`: the broker's stored copy, delivered on
|
||||
/// (re)subscribe, which may predate this session.
|
||||
pub fn vehicle(&mut self, rx: f64, signal: &str, payload: &str, retained: bool) -> io::Result<()> {
|
||||
let env = serde_json::from_str::<Value>(payload).unwrap_or_else(|_| Value::String(payload.to_string()));
|
||||
let mut v = json!({ "rx": rx, "signal": signal, "env": env });
|
||||
if retained {
|
||||
v["retained"] = json!(true);
|
||||
}
|
||||
writeln!(self.vehicle, "{v}")
|
||||
}
|
||||
|
||||
pub fn event(&mut self, kind: &str, mut extra: Value) -> io::Result<()> {
|
||||
if !extra.is_object() {
|
||||
extra = json!({});
|
||||
@@ -181,6 +195,7 @@ impl Session {
|
||||
pub fn sync(&mut self) -> io::Result<()> {
|
||||
sync(&mut self.can)?;
|
||||
sync(&mut self.gnss)?;
|
||||
sync(&mut self.vehicle)?;
|
||||
sync(&mut self.events)
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,9 @@ pub enum Event {
|
||||
CanLink(bool, String),
|
||||
Gnss(f64, String),
|
||||
GnssLink(bool, String),
|
||||
/// An envelope on yukart/vehicle/<signal>: receive time, signal, payload, retained.
|
||||
Vehicle(f64, String, String, bool),
|
||||
BusLink(bool, String),
|
||||
Ctl(String, mpsc::Sender<String>),
|
||||
}
|
||||
|
||||
@@ -124,6 +127,48 @@ pub fn spawn_gnss(addr: String, tx: SyncSender<Event>, stop: Arc<AtomicBool>) {
|
||||
}).expect("spawn gnss thread");
|
||||
}
|
||||
|
||||
/// Envelopes from the broker, for the record only: raw capture never waits on this.
|
||||
pub fn spawn_bus(addr: String, tx: SyncSender<Event>, stop: Arc<AtomicBool>) {
|
||||
let Some((host, port)) = addr.rsplit_once(':').and_then(|(h, p)| Some((h.to_string(), p.parse::<u16>().ok()?))) else {
|
||||
let _ = tx.send(Event::BusLink(false, format!("bad broker address {addr}")));
|
||||
return;
|
||||
};
|
||||
thread::Builder::new().name("bus".into()).spawn(move || {
|
||||
let mut opts = rumqttc::MqttOptions::new("recorder", host, port);
|
||||
opts.set_keep_alive(Duration::from_secs(5));
|
||||
let (client, mut connection) = rumqttc::Client::new(opts, 64);
|
||||
let mut net = rumqttc::NetworkOptions::new();
|
||||
net.set_tcp_nodelay(true);
|
||||
connection.eventloop.set_network_options(net);
|
||||
let mut up = false;
|
||||
for ev in connection.iter() {
|
||||
if stop.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
match ev {
|
||||
Ok(rumqttc::Event::Incoming(rumqttc::Packet::ConnAck(_))) => {
|
||||
up = true;
|
||||
let _ = client.try_subscribe("yukart/vehicle/+", rumqttc::QoS::AtMostOnce);
|
||||
let _ = tx.send(Event::BusLink(true, format!("{addr}: subscribed to yukart/vehicle/+")));
|
||||
}
|
||||
Ok(rumqttc::Event::Incoming(rumqttc::Packet::Publish(p))) => {
|
||||
let signal = p.topic.rsplit('/').next().unwrap_or_default().to_string();
|
||||
let payload = String::from_utf8_lossy(&p.payload).into_owned();
|
||||
// Never block the event loop on the writer; CAN frames have priority in the queue.
|
||||
let _ = tx.try_send(Event::Vehicle(clock::realtime(), signal, payload, p.retain));
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
if std::mem::take(&mut up) {
|
||||
let _ = tx.send(Event::BusLink(false, format!("{addr}: {e}")));
|
||||
}
|
||||
thread::sleep(Duration::from_secs(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}).expect("spawn bus thread");
|
||||
}
|
||||
|
||||
/// One command per connection, one reply line back.
|
||||
pub fn spawn_ctl(path: String, tx: SyncSender<Event>) -> std::io::Result<()> {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
Reference in New Issue
Block a user