feat: multi-port UDP listener with data dump
Listen on three AC telemetry ports simultaneously (9996, 10101, 5005). Dump received data to per-feed files under ./data/<session>/. Notify on console for each received packet. - Port 9996: AC built-in (ksudp), binary with 4-byte length framing - Port 10101: Telemetry Tool plugin, binary with 4-byte length framing - Port 5005: AC Lua plugin, JSON with newline-separated records - Configurable bind address via RUSTY_TELEMETRY_BIND (default 127.0.0.1) - Buffered file I/O via tokio BufWriter - Graceful shutdown with flush on Ctrl+C - Startup banner showing feed status - Bumped version to 0.2.0, added futures dependency
This commit is contained in:
parent
30543fd0ba
commit
1731495197
4 changed files with 302 additions and 45 deletions
275
src/main.rs
275
src/main.rs
|
|
@ -5,74 +5,263 @@ use stats::ReceiveStats;
|
|||
use telemetry::TelemetryFrame;
|
||||
|
||||
use std::io::Write;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncWriteExt, BufWriter};
|
||||
|
||||
const DEFAULT_BIND_ADDR: &str = "127.0.0.1:5005";
|
||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const BUFFER_SIZE: usize = 65535;
|
||||
|
||||
fn print_banner(bind_addr: &str) {
|
||||
fn bind_addr() -> String {
|
||||
std::env::var("RUSTY_TELEMETRY_BIND").unwrap_or_else(|_| "127.0.0.1".to_string())
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum FeedFormat {
|
||||
Binary,
|
||||
Json,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FeedConfig {
|
||||
port: u16,
|
||||
name: &'static str,
|
||||
label: &'static str,
|
||||
format: FeedFormat,
|
||||
}
|
||||
|
||||
static FEEDS: &[FeedConfig] = &[
|
||||
FeedConfig {
|
||||
port: 9996,
|
||||
name: "ksudp",
|
||||
label: "KSUDP",
|
||||
format: FeedFormat::Binary,
|
||||
},
|
||||
FeedConfig {
|
||||
port: 10101,
|
||||
name: "telemetry_tool",
|
||||
label: "Telemetry Tool",
|
||||
format: FeedFormat::Binary,
|
||||
},
|
||||
FeedConfig {
|
||||
port: 5005,
|
||||
name: "ac_plugin",
|
||||
label: "AC Lua Plugin",
|
||||
format: FeedFormat::Json,
|
||||
},
|
||||
];
|
||||
|
||||
fn print_banner(results: &[(&FeedConfig, bool)], session_dir: &str, bind: &str) {
|
||||
println!("========================================");
|
||||
println!(" rusty-telemetry listener");
|
||||
println!(" Phase 1 - UDP JSON receiver");
|
||||
println!(" rusty-telemetry v{VERSION} — Multi-feed telemetry listener");
|
||||
println!("========================================");
|
||||
println!(" Bind: {}", bind_addr);
|
||||
for (feed, ok) in results {
|
||||
let status = if *ok { "✅ Listening" } else { "❌ Failed" };
|
||||
println!(
|
||||
" Feed :{} [{}] {}",
|
||||
feed.port, feed.label, status
|
||||
);
|
||||
}
|
||||
println!("========================================");
|
||||
println!(" Bind: {bind}");
|
||||
println!(" Data dir: {session_dir}/");
|
||||
println!("========================================");
|
||||
println!();
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let bind_addr =
|
||||
std::env::var("RUSTY_TELEMETRY_BIND").unwrap_or_else(|_| DEFAULT_BIND_ADDR.to_string());
|
||||
|
||||
print_banner(&bind_addr);
|
||||
|
||||
let socket = match tokio::net::UdpSocket::bind(&bind_addr).await {
|
||||
Ok(s) => {
|
||||
let local_addr = s.local_addr().unwrap();
|
||||
println!("[INFO] Listening on {}", local_addr);
|
||||
s
|
||||
}
|
||||
async fn run_feed(
|
||||
feed: FeedConfig,
|
||||
socket: tokio::net::UdpSocket,
|
||||
session_dir: String,
|
||||
shutdown: Arc<tokio::sync::Notify>,
|
||||
) {
|
||||
let ext = match feed.format {
|
||||
FeedFormat::Binary => "bin",
|
||||
FeedFormat::Json => "json",
|
||||
};
|
||||
let file_path = format!(
|
||||
"{session_dir}/feed_{}_{}.{ext}",
|
||||
feed.port, feed.name
|
||||
);
|
||||
let file = match tokio::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&file_path)
|
||||
.await
|
||||
{
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
eprintln!("[ERROR] Failed to bind UDP socket: {}", e);
|
||||
std::process::exit(1);
|
||||
eprintln!(
|
||||
"[ERROR] Cannot open dump file for :{} ({}): {}",
|
||||
feed.port, feed.name, e
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut file = BufWriter::new(file);
|
||||
|
||||
let mut buf = vec![0u8; BUFFER_SIZE];
|
||||
let mut stats = ReceiveStats::new();
|
||||
let stdout = std::io::stdout();
|
||||
|
||||
loop {
|
||||
match socket.recv_from(&mut buf).await {
|
||||
Ok((len, src)) => {
|
||||
stats.record(len);
|
||||
tokio::select! {
|
||||
result = socket.recv_from(&mut buf) => {
|
||||
match result {
|
||||
Ok((len, _src)) => {
|
||||
stats.record(len);
|
||||
|
||||
let text = match std::str::from_utf8(&buf[..len]) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("[WARN] Invalid UTF-8 from {}: {}", src, e);
|
||||
continue;
|
||||
if let FeedFormat::Binary = feed.format {
|
||||
let header = (len as u32).to_be_bytes();
|
||||
if let Err(e) = file.write_all(&header).await {
|
||||
eprintln!(
|
||||
"[ERROR] Write error on :{} ({}): {}",
|
||||
feed.port, feed.name, e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = file.write_all(&buf[..len]).await {
|
||||
eprintln!(
|
||||
"[ERROR] Write error on :{} ({}): {}",
|
||||
feed.port, feed.name, e
|
||||
);
|
||||
}
|
||||
if let FeedFormat::Json = feed.format {
|
||||
let _ = file.write_all(b"\n").await;
|
||||
}
|
||||
|
||||
{
|
||||
let mut handle = stdout.lock();
|
||||
let fmt_tag = match feed.format {
|
||||
FeedFormat::Binary => "Binary",
|
||||
FeedFormat::Json => "JSON",
|
||||
};
|
||||
let _ = writeln!(
|
||||
handle,
|
||||
"[ :{} | {} ] {fmt_tag} packet: {} bytes (#{})",
|
||||
feed.port,
|
||||
feed.label,
|
||||
len,
|
||||
stats.total_packets,
|
||||
);
|
||||
if let FeedFormat::Json = feed.format {
|
||||
if let Ok(text) = std::str::from_utf8(&buf[..len]) {
|
||||
if let Ok(frame) =
|
||||
serde_json::from_str::<TelemetryFrame>(text)
|
||||
{
|
||||
let _ = writeln!(handle, "{frame}");
|
||||
} else {
|
||||
let _ = writeln!(handle, " [WARN] JSON parse failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = writeln!(handle, " {}", stats);
|
||||
let _ = writeln!(handle);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let frame: TelemetryFrame = match serde_json::from_str(text) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
eprintln!("[WARN] JSON parse error from {}: {}", src, e);
|
||||
continue;
|
||||
eprintln!(
|
||||
"[ERROR] Receive error on :{} ({}): {}",
|
||||
feed.port, feed.name, e
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
{
|
||||
let mut handle = stdout.lock();
|
||||
let _ = writeln!(handle, "{}", frame);
|
||||
let _ = writeln!(handle, " [{}] {}", src, stats);
|
||||
let _ = writeln!(handle);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[ERROR] Receive error: {}", e);
|
||||
_ = shutdown.notified() => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = file.flush().await {
|
||||
eprintln!(
|
||||
"[ERROR] Flush error on :{} ({}): {}",
|
||||
feed.port, feed.name, e
|
||||
);
|
||||
}
|
||||
if let Err(e) = file.get_ref().sync_all().await {
|
||||
eprintln!(
|
||||
"[ERROR] Sync error on :{} ({}): {}",
|
||||
feed.port, feed.name, e
|
||||
);
|
||||
}
|
||||
eprintln!(
|
||||
"[INFO] Feed :{} ({}) flushed and closed",
|
||||
feed.port, feed.name
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let bind = bind_addr();
|
||||
|
||||
let session_id = chrono::Local::now().format("%Y%m%d_%H%M%S").to_string();
|
||||
let session_dir = format!("./data/{session_id}");
|
||||
|
||||
if let Err(e) = tokio::fs::create_dir_all(&session_dir).await {
|
||||
eprintln!("[ERROR] Cannot create data dir {}: {}", session_dir, e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let mut banner_data: Vec<(&FeedConfig, bool)> = Vec::new();
|
||||
let mut bound_feeds: Vec<(FeedConfig, tokio::net::UdpSocket)> = Vec::new();
|
||||
|
||||
for feed in FEEDS {
|
||||
let addr = format!("{bind}:{}", feed.port);
|
||||
match tokio::net::UdpSocket::bind(&addr).await {
|
||||
Ok(socket) => {
|
||||
banner_data.push((feed, true));
|
||||
bound_feeds.push((feed.clone(), socket));
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"[WARN] Failed to bind :{} ({}): {} — skipping",
|
||||
feed.port, feed.label, e
|
||||
);
|
||||
banner_data.push((feed, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
print_banner(&banner_data, &session_dir, &bind);
|
||||
|
||||
if bound_feeds.is_empty() {
|
||||
eprintln!("[ERROR] No feeds could bind. Exiting.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let shutdown = Arc::new(tokio::sync::Notify::new());
|
||||
|
||||
let mut tasks = Vec::new();
|
||||
for (feed, socket) in bound_feeds {
|
||||
let dir = session_dir.clone();
|
||||
let shutdown = shutdown.clone();
|
||||
tasks.push(tokio::spawn(async move {
|
||||
run_feed(feed, socket, dir, shutdown).await;
|
||||
}));
|
||||
}
|
||||
|
||||
tokio::signal::ctrl_c().await.ok();
|
||||
println!();
|
||||
println!("[INFO] Ctrl+C received, shutting down...");
|
||||
|
||||
shutdown.notify_waiters();
|
||||
|
||||
match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(3),
|
||||
futures::future::join_all(tasks),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(results) => {
|
||||
for r in results {
|
||||
if let Err(e) = r {
|
||||
eprintln!("[WARN] Task join error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
eprintln!("[WARN] Timeout waiting for feeds to flush; forcing exit");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue