1use super::AppState;
32use axum::{
33 extract::State,
34 http::{HeaderMap, StatusCode},
35 response::{IntoResponse, Json},
36};
37use serde::{Deserialize, Serialize};
38use std::path::PathBuf;
39use tokio::fs;
40use tokio::io::AsyncWriteExt as _;
41
42const MAX_APPEND_BYTES: usize = 32_768; fn require_auth(
48 state: &AppState,
49 headers: &HeaderMap,
50) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
51 if !state.pairing.require_pairing() {
52 return Ok(());
53 }
54 let token = headers
55 .get(axum::http::header::AUTHORIZATION)
56 .and_then(|v| v.to_str().ok())
57 .and_then(|auth| auth.strip_prefix("Bearer "))
58 .unwrap_or("");
59 if state.pairing.is_authenticated(token) {
60 Ok(())
61 } else {
62 Err((
63 StatusCode::UNAUTHORIZED,
64 Json(serde_json::json!({
65 "error": "Unauthorized — pair first via POST /pair, then send Authorization: Bearer <token>"
66 })),
67 ))
68 }
69}
70
71fn hardware_dir() -> Result<PathBuf, String> {
75 directories::BaseDirs::new()
76 .map(|b| b.home_dir().join(".zeroclaw").join("hardware"))
77 .ok_or_else(|| "Cannot determine home directory".to_string())
78}
79
80fn validate_device_alias(alias: &str) -> Result<(), &'static str> {
83 if alias.is_empty() || alias.len() > 64 {
84 return Err("Device alias must be 1–64 characters");
85 }
86 if !alias
87 .chars()
88 .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
89 {
90 return Err("Device alias must contain only alphanumerics, hyphens, and underscores");
91 }
92 Ok(())
93}
94
95fn device_file_path(hw_dir: &std::path::Path, alias: &str) -> Result<PathBuf, &'static str> {
97 validate_device_alias(alias)?;
98 Ok(hw_dir.join("devices").join(format!("{alias}.md")))
99}
100
101#[derive(Debug, Deserialize)]
104pub struct PinRegistrationBody {
105 #[serde(default = "default_device")]
107 pub device: String,
108 pub pin: u32,
110 pub component: String,
112 #[serde(default)]
114 pub notes: String,
115}
116
117fn default_device() -> String {
118 "rpi0".to_string()
119}
120
121pub async fn handle_hardware_pin(
128 State(state): State<AppState>,
129 headers: HeaderMap,
130 body: Result<Json<PinRegistrationBody>, axum::extract::rejection::JsonRejection>,
131) -> impl IntoResponse {
132 if let Err(e) = require_auth(&state, &headers) {
133 return e.into_response();
134 }
135
136 let Json(req) = match body {
137 Ok(b) => b,
138 Err(e) => {
139 return (
140 StatusCode::BAD_REQUEST,
141 Json(serde_json::json!({ "error": format!("Invalid JSON: {e}") })),
142 )
143 .into_response();
144 }
145 };
146
147 if req.component.is_empty() {
148 return (
149 StatusCode::BAD_REQUEST,
150 Json(serde_json::json!({ "error": "\"component\" must not be empty" })),
151 )
152 .into_response();
153 }
154 let component = req.component.replace(['\n', '\r'], " ");
156 let notes = req.notes.replace(['\n', '\r'], " ");
157
158 let hw_dir = match hardware_dir() {
159 Ok(d) => d,
160 Err(e) => {
161 return (
162 StatusCode::INTERNAL_SERVER_ERROR,
163 Json(serde_json::json!({ "error": e })),
164 )
165 .into_response();
166 }
167 };
168
169 let device_path = match device_file_path(&hw_dir, &req.device) {
170 Ok(p) => p,
171 Err(e) => {
172 return (
173 StatusCode::BAD_REQUEST,
174 Json(serde_json::json!({ "error": e })),
175 )
176 .into_response();
177 }
178 };
179
180 if let Some(parent) = device_path.parent()
182 && let Err(e) = fs::create_dir_all(parent).await
183 {
184 return (
185 StatusCode::INTERNAL_SERVER_ERROR,
186 Json(serde_json::json!({ "error": format!("Failed to create directory: {e}") })),
187 )
188 .into_response();
189 }
190
191 let line = if notes.is_empty() {
192 format!("- GPIO {}: {}\n", req.pin, component)
193 } else {
194 format!("- GPIO {}: {} — {}\n", req.pin, component, notes)
195 };
196
197 match append_to_file(&device_path, &line).await {
198 Ok(()) => {
199 let message = format!(
200 "GPIO {} registered as {} on {}",
201 req.pin, component, req.device
202 );
203 ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"device": req.device, "pin": req.pin, "component": component})), &format!("{}", message));
204 (
205 StatusCode::OK,
206 Json(serde_json::json!({ "ok": true, "message": message })),
207 )
208 .into_response()
209 }
210 Err(e) => (
211 StatusCode::INTERNAL_SERVER_ERROR,
212 Json(serde_json::json!({ "error": format!("Failed to write: {e}") })),
213 )
214 .into_response(),
215 }
216}
217
218#[derive(Debug, Deserialize)]
221pub struct ContextAppendBody {
222 #[serde(default = "default_device")]
224 pub device: String,
225 pub content: String,
227}
228
229pub async fn handle_hardware_context_post(
231 State(state): State<AppState>,
232 headers: HeaderMap,
233 body: Result<Json<ContextAppendBody>, axum::extract::rejection::JsonRejection>,
234) -> impl IntoResponse {
235 if let Err(e) = require_auth(&state, &headers) {
236 return e.into_response();
237 }
238
239 let Json(req) = match body {
240 Ok(b) => b,
241 Err(e) => {
242 return (
243 StatusCode::BAD_REQUEST,
244 Json(serde_json::json!({ "error": format!("Invalid JSON: {e}") })),
245 )
246 .into_response();
247 }
248 };
249
250 if req.content.is_empty() {
251 return (
252 StatusCode::BAD_REQUEST,
253 Json(serde_json::json!({ "error": "\"content\" must not be empty" })),
254 )
255 .into_response();
256 }
257 if req.content.len() > MAX_APPEND_BYTES {
258 return (
259 StatusCode::PAYLOAD_TOO_LARGE,
260 Json(serde_json::json!({
261 "error": format!("Content too large — max {} bytes", MAX_APPEND_BYTES)
262 })),
263 )
264 .into_response();
265 }
266
267 let hw_dir = match hardware_dir() {
268 Ok(d) => d,
269 Err(e) => {
270 return (
271 StatusCode::INTERNAL_SERVER_ERROR,
272 Json(serde_json::json!({ "error": e })),
273 )
274 .into_response();
275 }
276 };
277
278 let device_path = match device_file_path(&hw_dir, &req.device) {
279 Ok(p) => p,
280 Err(e) => {
281 return (
282 StatusCode::BAD_REQUEST,
283 Json(serde_json::json!({ "error": e })),
284 )
285 .into_response();
286 }
287 };
288
289 if let Some(parent) = device_path.parent()
290 && let Err(e) = fs::create_dir_all(parent).await
291 {
292 return (
293 StatusCode::INTERNAL_SERVER_ERROR,
294 Json(serde_json::json!({ "error": format!("Failed to create directory: {e}") })),
295 )
296 .into_response();
297 }
298
299 let mut content = req.content.clone();
301 if !content.ends_with('\n') {
302 content.push('\n');
303 }
304
305 match append_to_file(&device_path, &content).await {
306 Ok(()) => {
307 ::zeroclaw_log::record!(
308 INFO,
309 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
310 .with_attrs(
311 ::serde_json::json!({"device": req.device, "bytes": content.len()})
312 ),
313 "Hardware context appended"
314 );
315 (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response()
316 }
317 Err(e) => (
318 StatusCode::INTERNAL_SERVER_ERROR,
319 Json(serde_json::json!({ "error": format!("Failed to write: {e}") })),
320 )
321 .into_response(),
322 }
323}
324
325#[derive(Debug, Serialize)]
328struct HardwareContextResponse {
329 hardware_md: String,
330 devices: std::collections::HashMap<String, String>,
331}
332
333pub async fn handle_hardware_context_get(
335 State(state): State<AppState>,
336 headers: HeaderMap,
337) -> impl IntoResponse {
338 if let Err(e) = require_auth(&state, &headers) {
339 return e.into_response();
340 }
341
342 let hw_dir = match hardware_dir() {
343 Ok(d) => d,
344 Err(e) => {
345 return (
346 StatusCode::INTERNAL_SERVER_ERROR,
347 Json(serde_json::json!({ "error": e })),
348 )
349 .into_response();
350 }
351 };
352
353 let hardware_md = fs::read_to_string(hw_dir.join("HARDWARE.md"))
355 .await
356 .unwrap_or_default();
357
358 let devices_dir = hw_dir.join("devices");
360 let mut devices = std::collections::HashMap::new();
361 if let Ok(mut entries) = fs::read_dir(&devices_dir).await {
362 while let Ok(Some(entry)) = entries.next_entry().await {
363 let path = entry.path();
364 if path.extension().and_then(|e| e.to_str()) == Some("md") {
365 let alias = path
366 .file_stem()
367 .and_then(|s| s.to_str())
368 .unwrap_or("")
369 .to_string();
370 if !alias.is_empty() {
371 let content = fs::read_to_string(&path).await.unwrap_or_default();
372 devices.insert(alias, content);
373 }
374 }
375 }
376 }
377
378 let resp = HardwareContextResponse {
379 hardware_md,
380 devices,
381 };
382 (StatusCode::OK, Json(resp)).into_response()
383}
384
385pub async fn handle_hardware_reload(
395 State(state): State<AppState>,
396 headers: HeaderMap,
397) -> impl IntoResponse {
398 if let Err(e) = require_auth(&state, &headers) {
399 return e.into_response();
400 }
401
402 let tool_count = state.tools_registry.len();
404
405 let context = zeroclaw_hardware::load_hardware_context_prompt(&[]);
407 let context_length = context.len();
408
409 ::zeroclaw_log::record!(
410 INFO,
411 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
412 ::serde_json::json!({"context_length": context_length, "tool_count": tool_count})
413 ),
414 "Hardware context reloaded (on-disk read)"
415 );
416
417 (
418 StatusCode::OK,
419 Json(serde_json::json!({
420 "ok": true,
421 "tools": tool_count,
422 "context_length": context_length,
423 })),
424 )
425 .into_response()
426}
427
428async fn append_to_file(path: &std::path::Path, content: &str) -> std::io::Result<()> {
431 let mut file = tokio::fs::OpenOptions::new()
432 .create(true)
433 .append(true)
434 .open(path)
435 .await?;
436 file.write_all(content.as_bytes()).await?;
437 file.flush().await?;
438 Ok(())
439}