diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4688df2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +.git +target +data/logs diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 92347c5..e35a548 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -1,4 +1,4 @@ -name: CodeView CI +name: CodeView ATO CI on: push: @@ -7,28 +7,22 @@ on: branches: [main] jobs: - lint: + rust: runs-on: docker + container: + image: rust:1.85-bookworm steps: - uses: actions/checkout@v4 - - name: Lint + - name: Test and clippy run: | - if [ -f package.json ]; then npm ci && npm run lint; else echo "no lint configured"; fi + rustup component add clippy + cargo test --workspace + cargo clippy --workspace -- -D warnings - tests: + integration: runs-on: docker - needs: lint + needs: rust steps: - uses: actions/checkout@v4 - - name: Unit tests - run: | - if [ -f package.json ]; then npm test; elif [ -f pyproject.toml ]; then pip install -e . && pytest; else echo "no tests configured"; fi - - docker-build: - runs-on: docker - needs: tests - steps: - - uses: actions/checkout@v4 - - name: Docker build - run: | - if [ -f Dockerfile ]; then docker build -t app:ci .; elif [ -f docker-compose.yml ]; then docker compose build; else echo "no docker build configured"; fi + - name: Build image (in runner DinD) + run: docker build -t ato:ci . diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..418f12a --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/target +**/__pycache__/ +*.pyc +.pytest_cache/ +.venv/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..6922c49 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,112 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ato-app" +version = "0.1.0" +dependencies = [ + "ato-core", + "serde", + "serde_json", +] + +[[package]] +name = "ato-core" +version = "0.1.0" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..d53060a --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,12 @@ +[workspace] +members = ["crates/ato-core", "crates/ato-app"] +resolver = "2" + +[workspace.package] +edition = "2021" +license = "MIT" +version = "0.1.0" + +[profile.release] +lto = true +codegen-units = 1 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..65fb194 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM rust:1.85-bookworm AS builder +WORKDIR /src +COPY Cargo.toml ./ +COPY crates ./crates +COPY config ./config +COPY spec ./spec +RUN cargo test --workspace && cargo build --release -p ato-app + +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY --from=builder /src/target/release/ato /usr/local/bin/ato +COPY config/ato.default.json /etc/ato/ato.default.json +ENV ATO_CONFIG=/etc/ato/ato.default.json +USER nobody +WORKDIR /etc/ato +ENTRYPOINT ["/usr/local/bin/ato"] diff --git a/README.md b/README.md index af72176..250f901 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,22 @@ # ato -First **CodeView** project — bootstrap mode. +Standalone **Automatic Train Operation** (no ETCS, no ATP). Lean Rust controller talking to [TrainSim](../trainsim). -- **Forgejo:** https://git.italiatts.online/rwsholdem/ato -- **Mode:** bootstrap (direct push to `main` allowed) -- **Switch to protected:** `./scripts/project-mode.sh rwsholdem ato protected` +## Layout -## Stack +| Path | Role | +|------|------| +| `crates/ato-core/` | Pure control logic (zero deps, no I/O) | +| `crates/ato-app/` | HTTP client + control loop | +| `config/` | Replaceable parameters (`ato.default.json`) | +| `spec/` | Interface examples (`interface.md`, JSON samples) | -Scaffold your app here. Cursor and OpenClaw use this repo as the source of truth. +## Run (via CodeView ato-lab DinD) + +```bash +cd /root/code-view/profiles/ato-lab +chmod +x run-e2e.sh +./run-e2e.sh +``` + +Detailed ATO design notes — *TODO*. diff --git a/config/ato.default.json b/config/ato.default.json new file mode 100644 index 0000000..39a662c --- /dev/null +++ b/config/ato.default.json @@ -0,0 +1,7 @@ +{ + "trainsim_url": "http://trainsim:8080", + "target_speed_mps": 10.0, + "tick_ms": 100, + "run_secs": 15, + "mode": "cruise" +} diff --git a/config/ato.e2e.json b/config/ato.e2e.json new file mode 100644 index 0000000..b248df8 --- /dev/null +++ b/config/ato.e2e.json @@ -0,0 +1,7 @@ +{ + "trainsim_url": "http://trainsim:8080", + "target_speed_mps": 8.0, + "tick_ms": 100, + "run_secs": 15, + "mode": "cruise" +} diff --git a/crates/ato-app/Cargo.toml b/crates/ato-app/Cargo.toml new file mode 100644 index 0000000..40189cd --- /dev/null +++ b/crates/ato-app/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "ato-app" +description = "ATO controller process — talks to TrainSim over HTTP" +version.workspace = true +edition.workspace = true +license.workspace = true + +[[bin]] +name = "ato" +path = "src/main.rs" + +[dependencies] +ato-core = { path = "../ato-core" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[lints.rust] +unsafe_code = "forbid" + +[lints.clippy] +all = "warn" +pedantic = "warn" diff --git a/crates/ato-app/src/config.rs b/crates/ato-app/src/config.rs new file mode 100644 index 0000000..10a3446 --- /dev/null +++ b/crates/ato-app/src/config.rs @@ -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 { + 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 { + 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 { + 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"); + } +} diff --git a/crates/ato-app/src/http.rs b/crates/ato-app/src/http.rs new file mode 100644 index 0000000..45f1893 --- /dev/null +++ b/crates/ato-app/src/http.rs @@ -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 { + 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"); + } +} diff --git a/crates/ato-app/src/main.rs b/crates/ato-app/src/main.rs new file mode 100644 index 0000000..61c81ac --- /dev/null +++ b/crates/ato-app/src/main.rs @@ -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()) +} diff --git a/crates/ato-app/src/sim_client.rs b/crates/ato-app/src/sim_client.rs new file mode 100644 index 0000000..891e704 --- /dev/null +++ b/crates/ato-app/src/sim_client.rs @@ -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 { + 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")); + } +} diff --git a/crates/ato-core/Cargo.toml b/crates/ato-core/Cargo.toml new file mode 100644 index 0000000..925fff5 --- /dev/null +++ b/crates/ato-core/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "ato-core" +description = "Pure ATO control logic (no I/O)" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints.rust] +unsafe_code = "forbid" + +[lints.clippy] +all = "warn" +pedantic = "warn" diff --git a/crates/ato-core/src/lib.rs b/crates/ato-core/src/lib.rs new file mode 100644 index 0000000..b1cb9aa --- /dev/null +++ b/crates/ato-core/src/lib.rs @@ -0,0 +1,114 @@ +//! Standalone automatic train operation — core logic only. +//! No ETCS. No ATP. No I/O. + +mod percent; + +pub use percent::{Actuation, Percent}; + +/// Train motion snapshot (meters, m/s). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Motion { + pub speed_mps: f64, + pub position_m: f64, +} + +/// Compute traction/brake to hold a target cruise speed. +/// +/// Simple bang-bang with deadband — easy to test and reason about. +pub fn cruise_actuation(speed_mps: f64, target_mps: f64) -> Actuation { + const DEADBAND: f64 = 0.05; + if !speed_mps.is_finite() || !target_mps.is_finite() || target_mps < 0.0 { + return Actuation::full_brake(); + } + if speed_mps + DEADBAND < target_mps { + Actuation::traction(Percent::MAX) + } else if speed_mps - DEADBAND > target_mps { + Actuation::brake(Percent::new(40)) + } else { + Actuation::coast() + } +} + +/// Deceleration (m/s²) needed to stop within `distance_m` at current speed. +pub fn decel_to_stop(speed_mps: f64, distance_m: f64) -> Option { + if speed_mps <= 0.0 { + return Some(0.0); + } + if distance_m <= 0.0 { + return None; + } + Some(speed_mps * speed_mps / (2.0 * distance_m)) +} + +/// Brake effort 0–100 from required deceleration and comfort limit. +pub fn brake_pct_for_decel(required_decel: f64, comfort_decel: f64) -> Percent { + if required_decel <= 0.0 { + return Percent::ZERO; + } + let ratio = (required_decel / comfort_decel).clamp(0.0, 1.0); + Percent::new((ratio * 100.0).round() as u8) +} + +/// Stop at a track position (m). Uses comfort decel until urgent. +pub fn stop_at_position(motion: Motion, stop_at_m: f64, track_length_m: f64) -> Actuation { + let comfort = 0.8_f64; + let dist = distance_ahead(motion.position_m, stop_at_m, track_length_m); + let Some(decel) = decel_to_stop(motion.speed_mps, dist) else { + return Actuation::full_brake(); + }; + if decel <= comfort { + return Actuation::brake(brake_pct_for_decel(decel, comfort)); + } + Actuation::full_brake() +} + +/// Distance along track to target, wrapping on a loop. +pub fn distance_ahead(position_m: f64, target_m: f64, track_length_m: f64) -> f64 { + if track_length_m <= 0.0 { + return 0.0; + } + let pos = normalize_pos(position_m, track_length_m); + let tgt = normalize_pos(target_m, track_length_m); + if tgt >= pos { + tgt - pos + } else { + track_length_m - pos + tgt + } +} + +fn normalize_pos(p: f64, length: f64) -> f64 { + let mut x = p % length; + if x < 0.0 { + x += length; + } + x +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cruise_speeds_up_from_standstill() { + let a = cruise_actuation(0.0, 10.0); + assert_eq!(a.traction, Percent::MAX); + assert_eq!(a.brake, Percent::ZERO); + } + + #[test] + fn cruise_brakes_when_fast() { + let a = cruise_actuation(12.0, 10.0); + assert_eq!(a.traction, Percent::ZERO); + assert!(a.brake.value() > 0); + } + + #[test] + fn decel_formula() { + assert!((decel_to_stop(10.0, 50.0).unwrap() - 1.0).abs() < 1e-9); + } + + #[test] + fn distance_on_loop() { + assert!((distance_ahead(900.0, 100.0, 1000.0) - 200.0).abs() < 1e-9); + } +} diff --git a/crates/ato-core/src/percent.rs b/crates/ato-core/src/percent.rs new file mode 100644 index 0000000..b69500f --- /dev/null +++ b/crates/ato-core/src/percent.rs @@ -0,0 +1,64 @@ +use std::fmt; + +/// Validated 0–100 percent command. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Percent(u8); + +impl Percent { + pub const ZERO: Self = Self(0); + pub const MAX: Self = Self(100); + + #[must_use] + pub fn new(value: u8) -> Self { + Self(value.min(100)) + } + + #[must_use] + pub fn value(self) -> u8 { + self.0 + } +} + +/// Traction and brake command (mutually applied via builder methods). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Actuation { + pub traction: Percent, + pub brake: Percent, +} + +impl Actuation { + #[must_use] + pub fn coast() -> Self { + Self { + traction: Percent::ZERO, + brake: Percent::ZERO, + } + } + + #[must_use] + pub fn traction(p: Percent) -> Self { + Self { + traction: p, + brake: Percent::ZERO, + } + } + + #[must_use] + pub fn brake(p: Percent) -> Self { + Self { + traction: Percent::ZERO, + brake: p, + } + } + + #[must_use] + pub fn full_brake() -> Self { + Self::brake(Percent::MAX) + } +} + +impl fmt::Display for Percent { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}%", self.0) + } +} diff --git a/spec/actuate.json b/spec/actuate.json new file mode 100644 index 0000000..59fcd60 --- /dev/null +++ b/spec/actuate.json @@ -0,0 +1,4 @@ +{ + "traction_pct": 0, + "brake_pct": 0 +} diff --git a/spec/interface.md b/spec/interface.md new file mode 100644 index 0000000..c7e29d5 --- /dev/null +++ b/spec/interface.md @@ -0,0 +1,45 @@ +# ATO ↔ TrainSim interface (v1) + +Standalone. No ETCS. No ATP. JSON over HTTP. + +Base URL (TrainSim): `http://trainsim:8080` + +## TrainSim → ATO reads + +`GET /train/state` + +```json +{ + "speed_mps": 0.0, + "position_m": 0.0, + "track_length_m": 1000.0, + "sim_running": false +} +``` + +## ATO → TrainSim writes + +`POST /train/actuate` + +```json +{ + "traction_pct": 0, + "brake_pct": 0 +} +``` + +Percent 0–100. Do not send both > 0 (TrainSim clamps brake wins). + +`POST /sim/start` — start physics tick (empty body) + +`POST /sim/stop` — pause physics (empty body) + +Optional (TrainSim logs only): `POST /train/doors` `{ "command": "open" | "close" }` + +## ATO configuration (env) + +| Variable | Meaning | +|----------|---------| +| `TRAINSIM_URL` | TrainSim base URL | +| `TARGET_SPEED_MPS` | Cruise target (default 10) | +| `TICK_MS` | Control period (default 100) | diff --git a/spec/train_state.json b/spec/train_state.json new file mode 100644 index 0000000..e2b59f3 --- /dev/null +++ b/spec/train_state.json @@ -0,0 +1,6 @@ +{ + "speed_mps": 0.0, + "position_m": 0.0, + "track_length_m": 1000.0, + "sim_running": false +}