#![cfg(feature = "hardware")]
use super::traits::Peripheral;
use crate::error::{Result, ZeptoError};
use crate::tools::{Tool, ToolCategory, ToolContext};
use async_trait::async_trait;
use serde_json::{json, Value};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::Mutex;
use tokio_serial::{SerialPortBuilderExt, SerialStream};
const ALLOWED_PATH_PREFIXES: &[&str] = &[
"/dev/ttyACM",
"/dev/ttyUSB",
"/dev/tty.usbmodem",
"/dev/cu.usbmodem",
"/dev/tty.usbserial",
"/dev/cu.usbserial",
"COM",
];
pub fn is_path_allowed(path: &str) -> bool {
ALLOWED_PATH_PREFIXES.iter().any(|p| path.starts_with(p))
}
const SERIAL_TIMEOUT_SECS: u64 = 5;
async fn send_request(port: &mut SerialStream, cmd: &str, args: Value) -> Result<Value> {
static ID: AtomicU64 = AtomicU64::new(0);
let id = ID.fetch_add(1, Ordering::Relaxed);
let id_str = id.to_string();
let req = json!({
"id": id_str,
"cmd": cmd,
"args": args
});
let line = format!("{}\n", req);
port.write_all(line.as_bytes())
.await
.map_err(|e| ZeptoError::Tool(format!("Serial write failed: {e}")))?;
port.flush()
.await
.map_err(|e| ZeptoError::Tool(format!("Serial flush failed: {e}")))?;
const MAX_RESPONSE_SIZE: usize = 64 * 1024;
let mut buf = Vec::new();
let mut b = [0u8; 1];
while port.read_exact(&mut b).await.is_ok() {
if b[0] == b'\n' {
break;
}
buf.push(b[0]);
if buf.len() > MAX_RESPONSE_SIZE {
return Err(ZeptoError::Tool(format!(
"Serial response exceeded max size ({} bytes)",
MAX_RESPONSE_SIZE
)));
}
}
let line_str = String::from_utf8_lossy(&buf);
let resp: Value = serde_json::from_str(line_str.trim())
.map_err(|e| ZeptoError::Tool(format!("Serial response parse error: {e}")))?;
let resp_id = resp["id"].as_str().unwrap_or("");
if resp_id != id_str {
return Err(ZeptoError::Tool(format!(
"Response id mismatch: expected {}, got {}",
id_str, resp_id
)));
}
Ok(resp)
}
pub(crate) struct SerialTransport {
port: Mutex<SerialStream>,
}
impl SerialTransport {
pub(crate) async fn request(&self, cmd: &str, args: Value) -> Result<String> {
let mut port = self.port.lock().await;
let resp = tokio::time::timeout(
std::time::Duration::from_secs(SERIAL_TIMEOUT_SECS),
send_request(&mut port, cmd, args),
)
.await
.map_err(|_| {
ZeptoError::Tool(format!(
"Serial request timed out after {}s",
SERIAL_TIMEOUT_SECS
))
})??;
let ok = resp["ok"].as_bool().unwrap_or(false);
let result = resp["result"]
.as_str()
.map(String::from)
.unwrap_or_else(|| resp["result"].to_string());
let error = resp["error"].as_str().map(String::from);
if ok {
Ok(result)
} else {
Err(ZeptoError::Tool(
error.unwrap_or_else(|| "Unknown device error".to_string()),
))
}
}
}
pub struct SerialPeripheral {
name: String,
board_type: String,
transport: Arc<SerialTransport>,
}
impl SerialPeripheral {
pub fn connect_to(path: &str, board: &str, baud: u32) -> Result<Self> {
if !is_path_allowed(path) {
return Err(ZeptoError::Tool(format!(
"Serial path not allowed: {}. Allowed: /dev/ttyACM*, /dev/ttyUSB*, /dev/tty.usbmodem*, /dev/cu.usbmodem*",
path
)));
}
let port = tokio_serial::new(path, baud)
.open_native_async()
.map_err(|e| ZeptoError::Tool(format!("Failed to open {}: {}", path, e)))?;
let name = format!("{}-{}", board, path.replace('/', "_"));
let transport = Arc::new(SerialTransport {
port: Mutex::new(port),
});
Ok(Self {
name,
board_type: board.to_string(),
transport,
})
}
}
#[async_trait]
impl Peripheral for SerialPeripheral {
fn name(&self) -> &str {
&self.name
}
fn board_type(&self) -> &str {
&self.board_type
}
async fn connect(&mut self) -> Result<()> {
Ok(())
}
async fn disconnect(&mut self) -> Result<()> {
Ok(())
}
async fn health_check(&self) -> bool {
self.transport.request("ping", json!({})).await.is_ok()
}
fn tools(&self) -> Vec<Box<dyn Tool>> {
vec![
Box::new(GpioReadTool {
transport: self.transport.clone(),
}),
Box::new(GpioWriteTool {
transport: self.transport.clone(),
}),
]
}
}
struct GpioReadTool {
transport: Arc<SerialTransport>,
}
#[async_trait]
impl Tool for GpioReadTool {
fn name(&self) -> &str {
"gpio_read"
}
fn description(&self) -> &str {
"Read the value (0 or 1) of a GPIO pin on a connected peripheral (e.g. STM32 Nucleo)"
}
fn compact_description(&self) -> &str {
"Read GPIO pin value"
}
fn category(&self) -> ToolCategory {
ToolCategory::Hardware
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"pin": {
"type": "integer",
"description": "GPIO pin number (e.g. 13 for LED on Nucleo)"
}
},
"required": ["pin"]
})
}
async fn execute(&self, args: Value, _ctx: &ToolContext) -> crate::error::Result<String> {
let pin = args
.get("pin")
.and_then(|v| v.as_u64())
.ok_or_else(|| ZeptoError::Tool("Missing 'pin' parameter".into()))?;
self.transport
.request("gpio_read", json!({ "pin": pin }))
.await
}
}
struct GpioWriteTool {
transport: Arc<SerialTransport>,
}
#[async_trait]
impl Tool for GpioWriteTool {
fn name(&self) -> &str {
"gpio_write"
}
fn description(&self) -> &str {
"Set a GPIO pin high (1) or low (0) on a connected peripheral (e.g. turn on/off LED)"
}
fn compact_description(&self) -> &str {
"Write GPIO pin value"
}
fn category(&self) -> ToolCategory {
ToolCategory::Hardware
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"pin": {
"type": "integer",
"description": "GPIO pin number"
},
"value": {
"type": "integer",
"description": "0 for low, 1 for high"
}
},
"required": ["pin", "value"]
})
}
async fn execute(&self, args: Value, _ctx: &ToolContext) -> crate::error::Result<String> {
let pin = args
.get("pin")
.and_then(|v| v.as_u64())
.ok_or_else(|| ZeptoError::Tool("Missing 'pin' parameter".into()))?;
let value = args
.get("value")
.and_then(|v| v.as_u64())
.ok_or_else(|| ZeptoError::Tool("Missing 'value' parameter".into()))?;
self.transport
.request("gpio_write", json!({ "pin": pin, "value": value }))
.await
}
}