csi-webserver 0.1.1

REST/WebSocket bridge for streaming ESP32 CSI data over USB serial
Documentation
//! Binary entrypoint and composition root for `csi-webserver`.
//!
//! This module builds the complete runtime graph for the service:
//! - parses CLI bind options,
//! - configures tracing and log filtering,
//! - discovers the ESP32 serial endpoint,
//! - creates cross-task channels,
//! - spawns the serial background worker,
//! - mounts Axum routes and starts the TCP listener.
//!
//! # Service role
//!
//! `csi-webserver` is a bridge between ESP32 CSI firmware output and network
//! consumers. Incoming serial frames are forwarded by the background task to:
//! - WebSocket subscribers (`/api/ws`),
//! - session dump files on disk,
//! - or both, depending on configured output mode.
//!
//! Configuration and control commands are exposed as HTTP endpoints and sent to
//! the serial worker through an async command channel.
//!
//! # Startup lifecycle
//!
//! 1. Parse `--interface` and `--port`.
//! 2. Initialize tracing using `RUST_LOG` when provided.
//! 3. Detect a candidate ESP32 serial device (or exit with an error).
//! 4. Initialize runtime channels for commands, CSI frames, log mode, output
//!    mode, and session dump-file path signaling.
//! 5. Assemble shared [`AppState`](crate::state::AppState) and spawn
//!    [`serial::run_serial_task`].
//! 6. Register HTTP routes and begin serving requests.
//!
//! # Route surface
//!
//! - `GET /` basic health text response.
//! - `/api/config/*` device and parser configuration.
//! - `/api/control/*` collection lifecycle and runtime status.
//! - `GET /api/ws` live binary CSI frame stream.
//!
//! # Failure behavior
//!
//! If initial serial detection fails, startup exits with status code `1`.
//! After startup, serial disconnects are handled by the background worker's
//! reconnect loop, while HTTP routes continue to serve status and errors.

mod models;
mod routes;
mod serial;
mod state;

use std::sync::Arc;
use std::sync::atomic::AtomicBool;

use axum::{
    Router,
    routing::{get, post},
};
use clap::Parser;
use tokio::sync::{Mutex, broadcast, mpsc, watch};

use models::{DeviceConfig, LogMode, OutputMode};
use state::AppState;

// ─── CLI ──────────────────────────────────────────────────────────────────

#[derive(Parser, Debug)]
#[command(
    version,
    about = "CSI WebServer — streams ESP32 CSI data over WebSocket"
)]
struct Cli {
    /// Network interface to bind to.
    #[arg(long, default_value = "0.0.0.0")]
    interface: String,

    /// TCP port to listen on.
    #[arg(long, default_value_t = 3000)]
    port: u16,

    /// UART baud rate used to talk to the ESP32. Falls back to the
    /// `CSI_BAUD_RATE` environment variable when the flag is omitted.
    #[arg(long, env = "CSI_BAUD_RATE", default_value_t = 115_200)]
    baud_rate: u32,
}

#[tokio::main]
async fn main() {
    // ── CLI args ──────────────────────────────────────────────────────────
    let cli = Cli::parse();

    // ── Tracing ───────────────────────────────────────────────────────────
    tracing_subscriber::fmt()
        .with_env_filter(
            tracing_subscriber::EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| "csi_webserver=debug".into()),
        )
        .init();

    // ── Serial port detection ─────────────────────────────────────────────
    let port_path = match serial::detect_esp_port() {
        Ok(p) => p,
        Err(e) => {
            tracing::error!("{e}");
            std::process::exit(1);
        }
    };

    // ── Channels ──────────────────────────────────────────────────────────
    // cmd_tx        → serial task (CLI commands)
    // csi_tx        → all WebSocket clients (raw CSI frame bytes)
    // log_mode_tx   → serial task (frame-delimiter mode)
    // output_mode_tx→ serial task (stream / dump / both)
    // session_file_tx→serial task (current session dump file path)
    let (cmd_tx, cmd_rx) = mpsc::channel::<String>(64);
    let (csi_tx, _) = broadcast::channel::<Vec<u8>>(256);
    let (log_mode_tx, log_mode_rx) = watch::channel(LogMode::default());
    let (output_mode_tx, output_mode_rx) = watch::channel(OutputMode::default());
    let (session_file_tx, session_file_rx) = watch::channel::<Option<String>>(None);
    let (info_request_tx, info_request_rx) = mpsc::channel::<state::InfoResponder>(4);

    // ── Shared state ──────────────────────────────────────────────────────
    let state = AppState {
        port_path: Arc::new(Mutex::new(port_path.clone())),
        baud_rate: cli.baud_rate,
        serial_connected: Arc::new(AtomicBool::new(false)),
        collection_running: Arc::new(AtomicBool::new(false)),
        cmd_tx,
        csi_tx: csi_tx.clone(),
        log_mode_tx: Arc::new(log_mode_tx),
        output_mode_tx: Arc::new(output_mode_tx),
        session_file_tx: Arc::new(session_file_tx),
        config: Arc::new(Mutex::new(DeviceConfig::default())),
        info_request_tx,
        firmware_verified: Arc::new(AtomicBool::new(false)),
        device_info: Arc::new(Mutex::new(None)),
    };

    // ── Serial background task ────────────────────────────────────────────
    tokio::spawn(serial::run_serial_task(
        port_path,
        cli.baud_rate,
        cmd_rx,
        csi_tx,
        log_mode_rx,
        output_mode_rx,
        session_file_rx,
        info_request_rx,
        state.serial_connected.clone(),
        state.collection_running.clone(),
        state.firmware_verified.clone(),
        state.device_info.clone(),
        state.port_path.clone(),
    ));

    // ── Router ────────────────────────────────────────────────────────────
    let app = Router::new()
        .route("/", get(|| async { "CSI Server Active" }))
        // Config
        .route("/api/config", get(routes::config::get_config))
        .route("/api/config/reset", post(routes::config::reset_config))
        .route("/api/config/wifi", post(routes::config::set_wifi))
        .route("/api/config/traffic", post(routes::config::set_traffic))
        .route("/api/config/csi", post(routes::config::set_csi))
        .route(
            "/api/config/collection-mode",
            post(routes::config::set_collection_mode),
        )
        .route("/api/config/log-mode", post(routes::config::set_log_mode))
        .route(
            "/api/config/output-mode",
            post(routes::config::set_output_mode),
        )
        .route("/api/config/rate", post(routes::config::set_rate))
        .route("/api/config/io-tasks", post(routes::config::set_io_tasks))
        .route(
            "/api/config/csi-delivery",
            post(routes::config::set_csi_delivery),
        )
        // Control
        .route(
            "/api/control/start",
            post(routes::control::start_collection),
        )
        .route(
            "/api/control/stop",
            post(routes::control::stop_collection),
        )
        .route(
            "/api/control/status",
            get(routes::control::get_collection_status),
        )
        .route("/api/control/reset", post(routes::control::reset_esp32))
        .route("/api/control/stats", post(routes::config::show_stats))
        // Firmware identification
        .route("/api/info", get(routes::info::get_info))
        // WebSocket
        .route("/api/ws", get(routes::ws::ws_handler))
        .with_state(state);

    // ── Serve ─────────────────────────────────────────────────────────────
    let addr = format!("{}:{}", cli.interface, cli.port);
    let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
    tracing::info!("CSI server listening on http://{addr}");
    axum::serve(listener, app).await.unwrap();
}