A telemetry server for TrainLCD. It provides real-time event streaming via WebSocket, a REST API for data ingestion, and a GraphQL API for aggregated reporting β all backed by optional PostgreSQL persistence.
- WebSocket β Real-time broadcast of location updates and log events
- REST API β Location ingestion (
POST /api/location) and log submission (POST /api/log) - GraphQL β Aggregated per-line accuracy reports (
POST /graphql) - PostgreSQL persistence β Optionally stores all events in the database
- Ring buffer β Keeps the latest N events in memory (default 1000)
- Authentication β WebSocket subprotocol-based auth; REST Bearer token auth
- Line topology β Automatic segment annotation from a CSV topology file
- Rust 1.91+ (pinned via
rust-toolchain.toml) - PostgreSQL 18 (if persistence is enabled)
- Docker / Docker Compose (for containerized deployment)
# Basic startup
cargo run -- --host 0.0.0.0 --port 8080
# With a config file
cargo run -- --config config.toml
# With PostgreSQL persistence
cargo run -- --database-url postgres://user:pass@localhost:5432/thq
# With WebSocket auth
THQ_WS_AUTH_TOKEN=secret cargo run -- --host 0.0.0.0 --port 8080# Set the auth token in .env
echo 'THQ_WS_AUTH_TOKEN=your-secret' > .env
# Build & start (includes PostgreSQL)
docker compose up --buildEndpoints after startup:
| Endpoint | URL |
|---|---|
| WebSocket | ws://localhost:8080/ws |
| REST API | http://localhost:8080/api/location, /api/log |
| GraphQL Playground | http://localhost:8080/graphql |
| Health check | http://localhost:8080/healthz |
Values can be set via CLI arguments, environment variables, or a config file (config.toml).
host = "0.0.0.0"
port = 8080
ring_size = 1000
database_url = "postgres://user:pass@localhost:5432/thq"
ws_auth_token = "change-me"
ws_auth_required = true| Key | Environment variable | Default | Description |
|---|---|---|---|
host |
β | 127.0.0.1 |
Bind address |
port |
β | 8080 |
Listen port |
ring_size |
β | 1000 |
Ring buffer capacity |
database_url |
DATABASE_URL |
β | PostgreSQL connection URL |
ws_auth_token |
THQ_WS_AUTH_TOKEN |
β | Auth token |
ws_auth_required |
THQ_WS_AUTH_REQUIRED |
true* |
Require authentication |
* Defaults to true when a token is configured.
Authenticated endpoints require an Authorization: Bearer <token> header.
See openapi.yaml for the full specification.
{
"device": "device-001",
"state": "moving",
"lineId": 11302,
"coords": {
"latitude": 35.6812,
"longitude": 139.7671,
"accuracy": 10.0,
"speed": 45.0
},
"timestamp": 1706000000000
}{
"device": "device-001",
"timestamp": 1706000000000,
"log": {
"type": "app",
"level": "info",
"message": "GPS signal acquired"
}
}No authentication required. Returns 200 OK if the server is running.
Endpoint: ws://<host>:<port>/ws
Once connected, the server broadcasts location_update and log messages in real time.
Send the token via WebSocket subprotocols:
Sec-WebSocket-Protocol: thq, thq-auth-<token>
On success the server responds with Sec-WebSocket-Protocol: thq. When ws_auth_required is true, a missing or invalid token results in HTTP 401.
Set ws_auth_required = false to skip authentication during local development.
subscribe
{ "type": "subscribe", "device": "device-id" }location_update
{
"id": "uuid",
"type": "location_update",
"device": "device-id",
"state": "arrived | approaching | passing | moving",
"station_id": 123,
"line_id": 45,
"coords": {
"latitude": 35.0,
"longitude": 139.0,
"accuracy": 5.0,
"speed": 10.0
},
"timestamp": 1234567890
}log
{
"id": "uuid",
"type": "log",
"device": "device-id",
"timestamp": 1234567890,
"log": {
"type": "system | app | client",
"level": "debug | info | warn | error",
"message": "System operational"
}
}error
{
"type": "error",
"error": {
"type": "websocket_message_error | json_parse_error | payload_parse_error | accuracy_low | invalid_coords | unknown",
"reason": "..."
}
}Endpoint: POST /graphql (Playground: GET /graphql)
Returns aggregated accuracy metrics per line. Raw location data is never exposed.
query {
accuracyByLine(
lineId: "45"
from: "2024-12-01T00:00:00Z"
to: "2024-12-03T00:00:00Z"
bucketSize: HOUR
limit: 100
) {
lineId
buckets {
bucketStart
bucketEnd
avgAccuracy
p90Accuracy
sampleCount
}
}
}| Parameter | Type | Description |
|---|---|---|
lineId |
ID! |
Line ID |
from |
DateTime! |
Start of the time range |
to |
DateTime! |
End of the time range |
bucketSize |
TimeBucketSize! |
MINUTE, HOUR, or DAY |
limit |
Int |
Max buckets returned (default 500, cap 2000) |
Maximum time span per bucket size: MINUTE β€ 7 days, HOUR β€ 90 days, DAY β€ 365 days.
When database_url / DATABASE_URL is provided, the server connects to PostgreSQL, auto-creates tables, and stores every event.
| Table | Key columns |
|---|---|
location_logs |
id, device, state, station_id, line_id, segment_id, from_station_id, to_station_id, latitude, longitude, accuracy, speed, battery_level, battery_state, timestamp, recorded_at |
log_events |
id, device, log_type, log_level, message, timestamp, recorded_at |
Without a database_url the server still accepts WebSocket traffic but does not persist messages.
src/
βββ main.rs # Entrypoint
βββ config.rs # CLI arguments & config file parsing
βββ server.rs # Axum HTTP / WebSocket server
βββ state.rs # Shared application state
βββ domain.rs # Domain model definitions
βββ storage.rs # PostgreSQL persistence layer
βββ graphql.rs # GraphQL schema & resolvers
βββ segment.rs # Line topology & segment inference
βββ static/
βββ join.csv # Line topology data