Add standalone ATO controller in lean Rust.
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:
parent
b4e580af11
commit
a8d40b466b
20 changed files with 748 additions and 24 deletions
57
crates/ato-app/src/config.rs
Normal file
57
crates/ato-app/src/config.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
74
crates/ato-app/src/http.rs
Normal file
74
crates/ato-app/src/http.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
67
crates/ato-app/src/main.rs
Normal file
67
crates/ato-app/src/main.rs
Normal 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())
|
||||
}
|
||||
90
crates/ato-app/src/sim_client.rs
Normal file
90
crates/ato-app/src/sim_client.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue