Add standalone ATO controller in lean Rust.
Some checks are pending
CodeView ATO CI / rust (push) Waiting to run
CodeView ATO CI / integration (push) Blocked by required conditions

Pure ato-core logic, minimal HTTP client, JSON config and spec files.
Docker build with tests; integrates with TrainSim over simple REST API.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Mona Lisa 2026-06-14 20:37:21 +00:00
parent b4e580af11
commit a8d40b466b
20 changed files with 748 additions and 24 deletions

View file

@ -0,0 +1,57 @@
use serde::Deserialize;
use std::{fs, path::Path};
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct AtoConfig {
pub trainsim_url: String,
pub target_speed_mps: f64,
pub tick_ms: u64,
#[serde(default)]
pub run_secs: u64,
#[serde(default = "default_mode")]
pub mode: String,
}
fn default_mode() -> String {
"cruise".to_string()
}
impl AtoConfig {
pub fn load() -> Result<Self, String> {
let path = std::env::var("ATO_CONFIG").unwrap_or_else(|_| "config/ato.default.json".to_string());
Self::load_from(&path)
}
pub fn load_from(path: &str) -> Result<Self, String> {
let text = fs::read_to_string(Path::new(path)).map_err(|e| format!("read {path}: {e}"))?;
let cfg: Self = serde_json::from_str(&text).map_err(|e| format!("parse {path}: {e}"))?;
cfg.validate()
}
fn validate(self) -> Result<Self, String> {
if self.trainsim_url.is_empty() {
return Err("trainsim_url empty".into());
}
if !self.target_speed_mps.is_finite() || self.target_speed_mps < 0.0 {
return Err("target_speed_mps invalid".into());
}
if self.tick_ms == 0 {
return Err("tick_ms must be > 0".into());
}
Ok(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_inline_json() {
let cfg: AtoConfig = serde_json::from_str(
r#"{"trainsim_url":"http://x","target_speed_mps":10.0,"tick_ms":100,"run_secs":0,"mode":"cruise"}"#,
)
.unwrap();
assert_eq!(cfg.mode, "cruise");
}
}

View file

@ -0,0 +1,74 @@
//! Tiny HTTP/1.1 client (std only).
use std::io::{Read, Write};
use std::net::TcpStream;
use std::time::Duration;
pub fn get(url: &str) -> Result<(u16, String), String> {
request("GET", url, None)
}
pub fn post_json(url: &str, body: &str) -> Result<u16, String> {
request("POST", url, Some(body)).map(|(s, _)| s)
}
fn request(method: &str, url: &str, body: Option<&str>) -> Result<(u16, String), String> {
let (host, port, path) = parse_url(url)?;
let mut stream = TcpStream::connect((host.as_str(), port))
.map_err(|e| format!("connect: {e}"))?;
stream
.set_read_timeout(Some(Duration::from_secs(3)))
.map_err(|e| e.to_string())?;
stream
.set_write_timeout(Some(Duration::from_secs(3)))
.map_err(|e| e.to_string())?;
let payload = body.unwrap_or("");
let req = format!(
"{method} {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\nAccept: application/json\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{payload}",
payload.len()
);
stream.write_all(req.as_bytes()).map_err(|e| e.to_string())?;
let mut raw = String::new();
stream.read_to_string(&mut raw).map_err(|e| e.to_string())?;
parse_response(&raw)
}
fn parse_response(raw: &str) -> Result<(u16, String), String> {
let (head, body) = raw.split_once("\r\n\r\n").ok_or("bad response")?;
let status_line = head.lines().next().ok_or("no status")?;
let code: u16 = status_line
.split_whitespace()
.nth(1)
.ok_or("no code")?
.parse()
.map_err(|_| "bad code".to_string())?;
Ok((code, body.to_string()))
}
fn parse_url(url: &str) -> Result<(String, u16, String), String> {
let rest = url.strip_prefix("http://").ok_or("only http supported")?;
let (host_port, path) = match rest.split_once('/') {
Some((hp, p)) => (hp, format!("/{p}")),
None => (rest, "/".to_string()),
};
let (host, port) = match host_port.split_once(':') {
Some((h, p)) => (h.to_string(), p.parse().map_err(|_| "bad port")?),
None => (host_port.to_string(), 80),
};
Ok((host, port, path))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_simple_url() {
let (h, p, path) = parse_url("http://trainsim:8080/train/state").unwrap();
assert_eq!(h, "trainsim");
assert_eq!(p, 8080);
assert_eq!(path, "/train/state");
}
}

View file

@ -0,0 +1,67 @@
mod config;
mod http;
mod sim_client;
use ato_core::{cruise_actuation, Motion};
use config::AtoConfig;
use sim_client::{ActuateBody, SimClient};
use std::{thread, time::Duration};
fn main() {
if let Err(msg) = run() {
eprintln!("ato: {msg}");
std::process::exit(1);
}
}
fn run() -> Result<(), String> {
let cfg = AtoConfig::load()?;
let client = SimClient::new(&cfg.trainsim_url);
wait_for_sim(&client)?;
client.sim_start()?;
eprintln!(
"ato: mode={} target={} m/s",
cfg.mode, cfg.target_speed_mps
);
let start = std::time::Instant::now();
loop {
let state = client.read_state()?;
let cmd = cruise_actuation(
Motion {
speed_mps: state.speed_mps,
position_m: state.position_m,
}
.speed_mps,
cfg.target_speed_mps,
);
client.actuate(&ActuateBody {
traction_pct: cmd.traction.value(),
brake_pct: cmd.brake.value(),
})?;
if cfg.run_secs > 0 && start.elapsed().as_secs() >= cfg.run_secs {
client.actuate(&ActuateBody {
traction_pct: 0,
brake_pct: 100,
})?;
client.sim_stop()?;
eprintln!("ato: finished after {}s", cfg.run_secs);
break;
}
thread::sleep(Duration::from_millis(cfg.tick_ms));
}
Ok(())
}
fn wait_for_sim(client: &SimClient) -> Result<(), String> {
for _ in 0..60 {
if client.health().is_ok() {
return Ok(());
}
thread::sleep(Duration::from_secs(1));
}
Err("TrainSim not reachable".into())
}

View file

@ -0,0 +1,90 @@
//! Minimal HTTP client for TrainSim (see spec/interface.md).
use crate::http::{get, post_json};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct TrainState {
pub speed_mps: f64,
pub position_m: f64,
pub track_length_m: f64,
pub sim_running: bool,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct ActuateBody {
pub traction_pct: u8,
pub brake_pct: u8,
}
pub struct SimClient {
base: String,
}
impl SimClient {
#[must_use]
pub fn new(base: &str) -> Self {
Self {
base: base.trim_end_matches('/').to_string(),
}
}
pub fn health(&self) -> Result<(), String> {
let (code, _) = get(&format!("{}/health", self.base))?;
if code == 200 {
Ok(())
} else {
Err(format!("health status {code}"))
}
}
pub fn read_state(&self) -> Result<TrainState, String> {
let (code, body) = get(&format!("{}/train/state", self.base))?;
if code != 200 {
return Err(format!("state status {code}"));
}
serde_json::from_str(&body).map_err(|e| e.to_string())
}
pub fn actuate(&self, body: &ActuateBody) -> Result<(), String> {
let json = serde_json::to_string(body).map_err(|e| e.to_string())?;
let code = post_json(&format!("{}/train/actuate", self.base), &json)?;
if code == 200 {
Ok(())
} else {
Err(format!("actuate status {code}"))
}
}
pub fn sim_start(&self) -> Result<(), String> {
post_ok(&self.base, "/sim/start")
}
pub fn sim_stop(&self) -> Result<(), String> {
post_ok(&self.base, "/sim/stop")
}
}
fn post_ok(base: &str, path: &str) -> Result<(), String> {
let code = post_json(&format!("{base}{path}"), "{}")?;
if code == 200 {
Ok(())
} else {
Err(format!("post {path} status {code}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn actuate_body_serializes() {
let b = ActuateBody {
traction_pct: 50,
brake_pct: 0,
};
let j = serde_json::to_string(&b).unwrap();
assert!(j.contains("50"));
}
}