use super::AppState;
use axum::{
extract::{State},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Json},
};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use tokio::fs;
use tokio::io::AsyncWriteExt as _;
const MAX_APPEND_BYTES: usize = 32_768;
fn require_auth(
state: &AppState,
headers: &HeaderMap,
) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
if !state.pairing.require_pairing() {
return Ok(());
}
let token = headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|auth| auth.strip_prefix("Bearer "))
.unwrap_or("");
if state.pairing.is_authenticated(token) {
Ok(())
} else {
Err((
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({
"error": "Unauthorized — pair first via POST /pair, then send Authorization: Bearer <token>"
})),
))
}
}
fn hardware_dir() -> Result<PathBuf, String> {
directories::BaseDirs::new()
.map(|b| b.home_dir().join(".zeroclaw").join("hardware"))
.ok_or_else(|| "Cannot determine home directory".to_string())
}
fn validate_device_alias(alias: &str) -> Result<(), &'static str> {
if alias.is_empty() || alias.len() > 64 {
return Err("Device alias must be 1–64 characters");
}
if !alias.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
return Err("Device alias must contain only alphanumerics, hyphens, and underscores");
}
Ok(())
}
fn device_file_path(hw_dir: &std::path::Path, alias: &str) -> Result<PathBuf, &'static str> {
validate_device_alias(alias)?;
Ok(hw_dir.join("devices").join(format!("{alias}.md")))
}
#[derive(Debug, Deserialize)]
pub struct PinRegistrationBody {
#[serde(default = "default_device")]
pub device: String,
pub pin: u32,
pub component: String,
#[serde(default)]
pub notes: String,
}
fn default_device() -> String {
"rpi0".to_string()
}
pub async fn handle_hardware_pin(
State(state): State<AppState>,
headers: HeaderMap,
body: Result<Json<PinRegistrationBody>, axum::extract::rejection::JsonRejection>,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
let Json(req) = match body {
Ok(b) => b,
Err(e) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": format!("Invalid JSON: {e}") })),
)
.into_response()
}
};
if req.component.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "\"component\" must not be empty" })),
)
.into_response();
}
let component = req.component.replace(['\n', '\r'], " ");
let notes = req.notes.replace(['\n', '\r'], " ");
let hw_dir = match hardware_dir() {
Ok(d) => d,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": e })),
)
.into_response()
}
};
let device_path = match device_file_path(&hw_dir, &req.device) {
Ok(p) => p,
Err(e) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": e })),
)
.into_response()
}
};
if let Some(parent) = device_path.parent() {
if let Err(e) = fs::create_dir_all(parent).await {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": format!("Failed to create directory: {e}") })),
)
.into_response();
}
}
let line = if notes.is_empty() {
format!("- GPIO {}: {}\n", req.pin, component)
} else {
format!("- GPIO {}: {} — {}\n", req.pin, component, notes)
};
match append_to_file(&device_path, &line).await {
Ok(()) => {
let message = format!(
"GPIO {} registered as {} on {}",
req.pin, component, req.device
);
tracing::info!(device = %req.device, pin = req.pin, component = %component, "{}", message);
(
StatusCode::OK,
Json(serde_json::json!({ "ok": true, "message": message })),
)
.into_response()
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": format!("Failed to write: {e}") })),
)
.into_response(),
}
}
#[derive(Debug, Deserialize)]
pub struct ContextAppendBody {
#[serde(default = "default_device")]
pub device: String,
pub content: String,
}
pub async fn handle_hardware_context_post(
State(state): State<AppState>,
headers: HeaderMap,
body: Result<Json<ContextAppendBody>, axum::extract::rejection::JsonRejection>,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
let Json(req) = match body {
Ok(b) => b,
Err(e) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": format!("Invalid JSON: {e}") })),
)
.into_response()
}
};
if req.content.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "\"content\" must not be empty" })),
)
.into_response();
}
if req.content.len() > MAX_APPEND_BYTES {
return (
StatusCode::PAYLOAD_TOO_LARGE,
Json(serde_json::json!({
"error": format!("Content too large — max {} bytes", MAX_APPEND_BYTES)
})),
)
.into_response();
}
let hw_dir = match hardware_dir() {
Ok(d) => d,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": e })),
)
.into_response()
}
};
let device_path = match device_file_path(&hw_dir, &req.device) {
Ok(p) => p,
Err(e) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": e })),
)
.into_response()
}
};
if let Some(parent) = device_path.parent() {
if let Err(e) = fs::create_dir_all(parent).await {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": format!("Failed to create directory: {e}") })),
)
.into_response();
}
}
let mut content = req.content.clone();
if !content.ends_with('\n') {
content.push('\n');
}
match append_to_file(&device_path, &content).await {
Ok(()) => {
tracing::info!(device = %req.device, bytes = content.len(), "Hardware context appended");
(StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response()
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": format!("Failed to write: {e}") })),
)
.into_response(),
}
}
#[derive(Debug, Serialize)]
struct HardwareContextResponse {
hardware_md: String,
devices: std::collections::HashMap<String, String>,
}
pub async fn handle_hardware_context_get(
State(state): State<AppState>,
headers: HeaderMap,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
let hw_dir = match hardware_dir() {
Ok(d) => d,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": e })),
)
.into_response()
}
};
let hardware_md = fs::read_to_string(hw_dir.join("HARDWARE.md"))
.await
.unwrap_or_default();
let devices_dir = hw_dir.join("devices");
let mut devices = std::collections::HashMap::new();
if let Ok(mut entries) = fs::read_dir(&devices_dir).await {
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("md") {
let alias = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
if !alias.is_empty() {
let content = fs::read_to_string(&path).await.unwrap_or_default();
devices.insert(alias, content);
}
}
}
}
let resp = HardwareContextResponse {
hardware_md,
devices,
};
(StatusCode::OK, Json(resp)).into_response()
}
pub async fn handle_hardware_reload(
State(state): State<AppState>,
headers: HeaderMap,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
let tool_count = state.tools_registry.len();
let context = crate::hardware::load_hardware_context_prompt(&[]);
let context_length = context.len();
tracing::info!(
context_length,
tool_count,
"Hardware context reloaded (on-disk read)"
);
(
StatusCode::OK,
Json(serde_json::json!({
"ok": true,
"tools": tool_count,
"context_length": context_length,
})),
)
.into_response()
}
async fn append_to_file(path: &std::path::Path, content: &str) -> std::io::Result<()> {
let mut file = tokio::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.await?;
file.write_all(content.as_bytes()).await?;
file.flush().await?;
Ok(())
}