use axum::{Json, extract::State, http::StatusCode};
use chrono::Local;
use std::sync::atomic::Ordering;
use tokio::sync::oneshot;
use tokio::time::{Duration, sleep, timeout};
use tokio_serial::{SerialPort, SerialPortBuilderExt};
use crate::{
models::{ApiResponse, CollectionStatusResponse, OutputMode, StartConfig},
state::AppState,
};
const POST_RESET_BOOT_DELAY: Duration = Duration::from_millis(800);
const POST_RESET_VERIFY_TIMEOUT: Duration = Duration::from_millis(3000);
pub async fn get_collection_status(
State(state): State<AppState>,
) -> (StatusCode, Json<CollectionStatusResponse>) {
let port_path = state.port_path.lock().await.clone();
(
StatusCode::OK,
Json(CollectionStatusResponse::from_state(
&state.serial_connected,
&state.collection_running,
port_path,
)),
)
}
pub async fn reset_esp32(State(state): State<AppState>) -> (StatusCode, Json<ApiResponse>) {
state.collection_running.store(false, Ordering::SeqCst);
let _ = state.session_file_tx.send(None);
state.firmware_verified.store(false, Ordering::SeqCst);
*state.device_info.lock().await = None;
if !state.serial_connected.load(Ordering::SeqCst) {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(ApiResponse {
success: false,
message: "ESP32 disconnected; serial command unavailable".to_string(),
}),
);
}
let current_port = state.port_path.lock().await.clone();
let mut port = match tokio_serial::new(current_port.as_str(), state.baud_rate).open_native_async() {
Ok(p) => p,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiResponse {
success: false,
message: format!("Failed to open serial port for reset: {e}"),
}),
);
}
};
#[cfg(unix)]
{
let _ = port.set_exclusive(false);
}
let _ = port.write_data_terminal_ready(false);
if let Err(e) = port.write_request_to_send(true) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiResponse {
success: false,
message: format!("RTS assert failed (adapter may not support it): {e}"),
}),
);
}
sleep(Duration::from_millis(100)).await;
let _ = port.write_request_to_send(false);
drop(port);
tracing::info!("ESP32 reset via RTS on {}", current_port);
sleep(POST_RESET_BOOT_DELAY).await;
let (resp_tx, resp_rx) = oneshot::channel();
if state.info_request_tx.send(resp_tx).await.is_err() {
return (
StatusCode::OK,
Json(ApiResponse {
success: true,
message:
"ESP32 reset triggered via RTS, but post-reset re-verification could not be \
queued (serial task is shutting down). Call GET /api/info to retry."
.to_string(),
}),
);
}
match timeout(POST_RESET_VERIFY_TIMEOUT, resp_rx).await {
Ok(Ok(Ok(info))) => (
StatusCode::OK,
Json(ApiResponse {
success: true,
message: format!(
"ESP32 reset; firmware re-verified: esp-csi-cli-rs/{} ({})",
info.banner_version,
info.chip.as_deref().unwrap_or("unknown chip"),
),
}),
),
Ok(Ok(Err(reason))) => (
StatusCode::OK,
Json(ApiResponse {
success: true,
message: format!(
"ESP32 reset; firmware identity could NOT be re-verified \
(esp-csi-cli-rs may not be flashed): {reason}. Command endpoints will \
return 412 Precondition Failed until verification succeeds."
),
}),
),
Ok(Err(_)) | Err(_) => (
StatusCode::OK,
Json(ApiResponse {
success: true,
message: "ESP32 reset; post-reset re-verification timed out. Call GET /api/info \
to retry."
.to_string(),
}),
),
}
}
pub async fn stop_collection(State(state): State<AppState>) -> (StatusCode, Json<ApiResponse>) {
if !state.serial_connected.load(Ordering::SeqCst) {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(ApiResponse {
success: false,
message: "ESP32 disconnected; serial command unavailable".to_string(),
}),
);
}
if let Some(blocked) = state.require_firmware() {
return blocked;
}
if !state.collection_running.load(Ordering::SeqCst) {
return (
StatusCode::OK,
Json(ApiResponse {
success: true,
message: "Collection not running".to_string(),
}),
);
}
match state.cmd_tx.send("q".to_string()).await {
Ok(_) => {
state.collection_running.store(false, Ordering::SeqCst);
let _ = state.session_file_tx.send(None);
(
StatusCode::OK,
Json(ApiResponse {
success: true,
message: "Collection stop requested".to_string(),
}),
)
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiResponse {
success: false,
message: format!("Failed to send stop: {e}"),
}),
),
}
}
pub async fn start_collection(
State(state): State<AppState>,
body: Option<Json<StartConfig>>,
) -> (StatusCode, Json<ApiResponse>) {
if !state.serial_connected.load(Ordering::SeqCst) {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(ApiResponse {
success: false,
message: "ESP32 disconnected; serial command unavailable".to_string(),
}),
);
}
if let Some(blocked) = state.require_firmware() {
return blocked;
}
if state
.collection_running
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(ApiResponse {
success: false,
message: "Collection already running".to_string(),
}),
);
}
let cmd = body
.map(|Json(b)| b.to_cli_command())
.unwrap_or_else(|| "start".to_string());
match state.cmd_tx.send(cmd.clone()).await {
Ok(_) => {
let path = format!("csi_dump_{}.bin", Local::now().format("%Y%m%d_%H%M%S"));
let current_mode = state.output_mode_tx.borrow().clone();
if matches!(current_mode, OutputMode::Dump | OutputMode::Both) {
tracing::info!("New session dump file: {path}");
}
let _ = state.session_file_tx.send(Some(path));
(
StatusCode::OK,
Json(ApiResponse {
success: true,
message: format!("Collection started: {cmd}"),
}),
)
}
Err(e) => {
state.collection_running.store(false, Ordering::SeqCst);
let (status, message) = if !state.serial_connected.load(Ordering::SeqCst) {
(
StatusCode::SERVICE_UNAVAILABLE,
"ESP32 disconnected; serial command unavailable".to_string(),
)
} else {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to start collection: {e}"),
)
};
(
status,
Json(ApiResponse {
success: false,
message,
}),
)
}
}
}