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

3
.dockerignore Normal file
View file

@ -0,0 +1,3 @@
.git
target
data/logs

View file

@ -1,4 +1,4 @@
name: CodeView CI name: CodeView ATO CI
on: on:
push: push:
@ -7,28 +7,22 @@ on:
branches: [main] branches: [main]
jobs: jobs:
lint: rust:
runs-on: docker runs-on: docker
container:
image: rust:1.85-bookworm
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Lint - name: Test and clippy
run: | 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 runs-on: docker
needs: lint needs: rust
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Unit tests - name: Build image (in runner DinD)
run: | run: docker build -t ato:ci .
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

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
/target
**/__pycache__/
*.pyc
.pytest_cache/
.venv/

112
Cargo.lock generated Normal file
View file

@ -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"

12
Cargo.toml Normal file
View file

@ -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

17
Dockerfile Normal file
View file

@ -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"]

View file

@ -1,11 +1,22 @@
# ato # 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 ## Layout
- **Mode:** bootstrap (direct push to `main` allowed)
- **Switch to protected:** `./scripts/project-mode.sh rwsholdem ato protected`
## 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*.

7
config/ato.default.json Normal file
View file

@ -0,0 +1,7 @@
{
"trainsim_url": "http://trainsim:8080",
"target_speed_mps": 10.0,
"tick_ms": 100,
"run_secs": 15,
"mode": "cruise"
}

7
config/ato.e2e.json Normal file
View file

@ -0,0 +1,7 @@
{
"trainsim_url": "http://trainsim:8080",
"target_speed_mps": 8.0,
"tick_ms": 100,
"run_secs": 15,
"mode": "cruise"
}

22
crates/ato-app/Cargo.toml Normal file
View file

@ -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"

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"));
}
}

View file

@ -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"

114
crates/ato-core/src/lib.rs Normal file
View file

@ -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<f64> {
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 0100 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);
}
}

View file

@ -0,0 +1,64 @@
use std::fmt;
/// Validated 0100 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)
}
}

4
spec/actuate.json Normal file
View file

@ -0,0 +1,4 @@
{
"traction_pct": 0,
"brake_pct": 0
}

45
spec/interface.md Normal file
View file

@ -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 0100. 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) |

6
spec/train_state.json Normal file
View file

@ -0,0 +1,6 @@
{
"speed_mps": 0.0,
"position_m": 0.0,
"track_length_m": 1000.0,
"sim_running": false
}