diff options
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/asist-app/src/main.rs | 27 | ||||
| -rw-r--r-- | crates/asist-app/src/registry.rs | 67 | ||||
| -rw-r--r-- | crates/asist-app/tests/integracion.rs | 129 | ||||
| -rw-r--r-- | crates/asist-core/src/config.rs | 49 | ||||
| -rw-r--r-- | crates/asist-core/src/lib.rs | 1 | ||||
| -rw-r--r-- | crates/asist-core/src/proc.rs | 228 | ||||
| -rw-r--r-- | crates/asist-core/src/tools.rs | 48 | ||||
| -rw-r--r-- | crates/asist-tools/src/camera.rs | 313 | ||||
| -rw-r--r-- | crates/asist-tools/src/lib.rs | 16 | ||||
| -rw-r--r-- | crates/asist-tools/src/screen.rs | 189 | ||||
| -rw-r--r-- | crates/asist-tools/src/search.rs | 34 | ||||
| -rw-r--r-- | crates/asist-tools/src/vision.rs | 206 |
12 files changed, 978 insertions, 329 deletions
diff --git a/crates/asist-app/src/main.rs b/crates/asist-app/src/main.rs index 7b00b1f..cc08e62 100644 --- a/crates/asist-app/src/main.rs +++ b/crates/asist-app/src/main.rs @@ -109,6 +109,17 @@ fn run(config: Config, args: &Args) -> Result<()> { // Las dos capacidades que tocan algo fuera del proceso se anuncian: una // enciende la cámara y la otra ejecuta órdenes. Quien lo arranca debería // saberlo sin tener que leerse la configuración. + if tools.get("mirar_la_pantalla").is_some() { + eprintln!( + "Captura de pantalla ACTIVA — a {} px{}", + config.screen.width, + if config.screen.save_dir.is_empty() { + String::new() + } else { + format!(", guardando en {}", config.screen.save_dir) + } + ); + } if tools.get("mirar_por_la_camara").is_some() { eprintln!( "Cámara ACTIVA — {} a {}x{}{}", @@ -320,6 +331,22 @@ fn check(config: &Config) -> Result<()> { } } + if !config.screen.enabled { + println!(" - pantalla desactivada en la configuración"); + } else { + match config.screen.command.first() { + Some(program) if !program.contains('/') || PathBuf::from(program).exists() => ok( + "pantalla", + format!("{} px · {}", config.screen.width, program), + ), + Some(program) => println!(" - pantalla no existe {program}"), + None => println!(" - pantalla screen.command está vacío"), + } + if std::env::var_os("WAYLAND_DISPLAY").is_none() && std::env::var_os("DISPLAY").is_none() { + println!(" - sesión gráfica no hay; la pantalla no se podrá capturar"); + } + } + if !config.camera.enabled { println!(" - cámara desactivada en la configuración"); } else if config.camera.device.exists() { diff --git a/crates/asist-app/src/registry.rs b/crates/asist-app/src/registry.rs index 13bbb75..a3a10ed 100644 --- a/crates/asist-app/src/registry.rs +++ b/crates/asist-app/src/registry.rs @@ -8,11 +8,12 @@ use std::sync::Arc; use std::time::Duration; -use asist_core::config::{CameraConfig, Config, SearchConfig}; +use asist_core::config::{self as cfg, Config, SearchConfig}; use asist_core::tools::ToolRegistry; use asist_llm::LlmClient; -use asist_tools::camera::CaptureConfig; -use asist_tools::{Camera, SearchBackend, WebSearch}; +use asist_tools::{ + Camera, CameraConfig, Screen, ScreenConfig, SearchBackend, VisionTool, WebSearch, +}; /// Lo que no se pudo activar y por qué, para decírselo al usuario al arrancar /// en vez de dejarlo en silencio. @@ -38,13 +39,16 @@ pub fn build(config: &Config, llm: &Arc<LlmClient>) -> (ToolRegistry, Vec<Skippe }), } - match camera_tool(&config.camera, llm) { - Ok(Some(tool)) => registry.register(Arc::new(tool)), - Ok(None) => {} - Err(reason) => skipped.push(Skipped { - tool: "mirar_por_la_camara", - reason, - }), + // Las dos que ven comparten forma, así que se registran igual. + for (result, tool) in [ + (camera_tool(&config.camera, llm), "mirar_por_la_camara"), + (screen_tool(&config.screen, llm), "mirar_la_pantalla"), + ] { + match result { + Ok(Some(vision)) => registry.register(Arc::new(vision)), + Ok(None) => {} + Err(reason) => skipped.push(Skipped { tool, reason }), + } } (registry, skipped) @@ -98,25 +102,46 @@ fn search_tool(config: &SearchConfig) -> Result<Option<WebSearch>, String> { ))) } -fn camera_tool(config: &CameraConfig, llm: &Arc<LlmClient>) -> Result<Option<Camera>, String> { +fn camera_tool( + config: &cfg::CameraConfig, + llm: &Arc<LlmClient>, +) -> Result<Option<VisionTool>, String> { if !config.enabled { return Ok(None); } - let capture = CaptureConfig { + let source = Camera::new(CameraConfig { device: config.device.clone(), width: config.width, height: config.height, warmup_frames: config.warmup_frames, timeout: Duration::from_secs(config.timeout_secs), save_dir: (!config.save_dir.is_empty()).then(|| config.save_dir.clone().into()), - }; - // Registrar una herramienta que va a fallar siempre es peor que no tenerla: - // el modelo la llamaría, se comería el error y gastaría un turno entero. - if !Camera::available(&capture) { - return Err(format!( - "no existe {}; comprueba con «v4l2-ctl --list-devices»", - capture.device.display() - )); + }); + finish(VisionTool::camera(Box::new(source), Arc::clone(llm))) +} + +fn screen_tool( + config: &cfg::ScreenConfig, + llm: &Arc<LlmClient>, +) -> Result<Option<VisionTool>, String> { + if !config.enabled { + return Ok(None); + } + let source = Screen::new(ScreenConfig { + command: config.command.clone(), + width: config.width, + output: config.output.clone(), + timeout: Duration::from_secs(config.timeout_secs), + save_dir: (!config.save_dir.is_empty()).then(|| config.save_dir.clone().into()), + }); + finish(VisionTool::screen(Box::new(source), Arc::clone(llm))) +} + +/// Registrar una herramienta que va a fallar siempre es peor que no tenerla: +/// el modelo la llamaría, se comería el error y gastaría un turno entero. +fn finish(tool: VisionTool) -> Result<Option<VisionTool>, String> { + match tool.available() { + Ok(()) => Ok(Some(tool)), + Err(err) => Err(err.to_string()), } - Ok(Some(Camera::new(capture, Arc::clone(llm)))) } diff --git a/crates/asist-app/tests/integracion.rs b/crates/asist-app/tests/integracion.rs index a7f7e15..d0fca38 100644 --- a/crates/asist-app/tests/integracion.rs +++ b/crates/asist-app/tests/integracion.rs @@ -17,6 +17,7 @@ use asist_core::http::Cancel; use asist_core::tools::{Tool, ToolRegistry}; use asist_llm::chat::Conversation; use asist_llm::{Delta, LlmClient, Message}; +use asist_tools::FrameSource; use asist_tts::TtsClient; /// Las pruebas se turnan la GPU. @@ -346,24 +347,62 @@ fn una_consulta_inventada_no_revienta_el_turno() { ); } -#[test] -fn la_camara_captura_un_jpeg() { - let config = config(); - let capture = asist_tools::camera::CaptureConfig { +fn camera_source(config: &Config) -> Option<asist_tools::Camera> { + if !config.camera.enabled || !config.camera.device.exists() { + eprintln!("sin cámara; prueba omitida"); + return None; + } + Some(asist_tools::Camera::new(asist_tools::CameraConfig { device: config.camera.device.clone(), width: config.camera.width, height: config.camera.height, warmup_frames: config.camera.warmup_frames, timeout: Duration::from_secs(config.camera.timeout_secs), save_dir: None, - }; - if !config.camera.enabled || !capture.device.exists() { - eprintln!("sin cámara; prueba omitida"); - return; + })) +} + +fn screen_source(config: &Config) -> Option<asist_tools::Screen> { + let screen = asist_tools::Screen::new(asist_tools::ScreenConfig { + command: config.screen.command.clone(), + width: config.screen.width, + output: config.screen.output.clone(), + timeout: Duration::from_secs(config.screen.timeout_secs), + save_dir: None, + }); + if !config.screen.enabled || screen.available().is_err() { + eprintln!("sin pantalla capturable; prueba omitida"); + return None; + } + Some(screen) +} + +/// Ancho de un JPEG, leído de su cabecera SOF. +fn jpeg_width(bytes: &[u8]) -> Option<u32> { + let mut i = 2; + while i + 9 < bytes.len() { + if bytes[i] != 0xFF { + return None; + } + let marker = bytes[i + 1]; + let len = u16::from_be_bytes([bytes[i + 2], bytes[i + 3]]) as usize; + if (0xC0..=0xCF).contains(&marker) && marker != 0xC4 && marker != 0xC8 && marker != 0xCC { + return Some(u16::from_be_bytes([bytes[i + 7], bytes[i + 8]]) as u32); + } + i += 2 + len; } + None +} + +#[test] +fn la_camara_captura_un_jpeg() { + let config = config(); + let Some(source) = camera_source(&config) else { + return; + }; let started = Instant::now(); - let frame = asist_tools::camera::capture(&capture).expect("la captura falló"); + let frame = source.capture().expect("la captura falló"); eprintln!( "fotograma de {} KB en {:?}", frame.len() / 1024, @@ -380,26 +419,43 @@ fn la_camara_captura_un_jpeg() { } #[test] +fn la_pantalla_se_captura_al_ancho_configurado() { + let config = config(); + let Some(source) = screen_source(&config) else { + return; + }; + + let started = Instant::now(); + let frame = source.capture().expect("la captura falló"); + eprintln!( + "captura de {} KB en {:?}", + frame.len() / 1024, + started.elapsed() + ); + assert_eq!(&frame[..2], &[0xFF, 0xD8], "no parece un JPEG"); + + // El ancho es lo que separa leer de inventar, así que se comprueba que el + // guion de verdad redujo la imagen y no la mandó a tamaño completo. + let width = jpeg_width(&frame).expect("no se pudo leer el ancho del JPEG"); + assert_eq!( + width, config.screen.width, + "la captura salió a {width} px y se pedían {}", + config.screen.width + ); +} + +#[test] #[ignore = "enciende la cámara y hace una pasada de visión; lento"] fn el_modelo_describe_lo_que_ve_la_camara() { let _gpu = en_exclusiva(); let Some((config, llm, _)) = clientes() else { return; }; - let capture = asist_tools::camera::CaptureConfig { - device: config.camera.device.clone(), - width: config.camera.width, - height: config.camera.height, - warmup_frames: config.camera.warmup_frames, - timeout: Duration::from_secs(config.camera.timeout_secs), - save_dir: None, - }; - if !config.camera.enabled || !capture.device.exists() { - eprintln!("sin cámara; prueba omitida"); + let Some(source) = camera_source(&config) else { return; - } + }; - let tool = asist_tools::Camera::new(capture, std::sync::Arc::new(llm)); + let tool = asist_tools::VisionTool::camera(Box::new(source), std::sync::Arc::new(llm)); let started = Instant::now(); let out = tool .call(&serde_json::json!({ "pregunta": "¿Qué se ve en la imagen?" })) @@ -418,6 +474,37 @@ fn el_modelo_describe_lo_que_ve_la_camara() { } #[test] +#[ignore = "captura la pantalla y hace una pasada de visión; lento"] +fn el_modelo_describe_la_pantalla() { + let _gpu = en_exclusiva(); + let Some((config, llm, _)) = clientes() else { + return; + }; + let Some(source) = screen_source(&config) else { + return; + }; + + let tool = asist_tools::VisionTool::screen(Box::new(source), std::sync::Arc::new(llm)); + let started = Instant::now(); + let out = tool + .call(&serde_json::json!({ "pregunta": "¿Qué hay en la pantalla?" })) + .expect("la descripción falló"); + + eprintln!("pantalla en {:?}: {out}", started.elapsed()); + assert!( + out.split_whitespace().count() >= 3, + "descripción vacía o mínima: {out}" + ); + // A 1280 px se midieron 7,6 s. Si esto se dispara, o la GPU está ocupada o + // alguien subió screen.width sin mirar el coste. + assert!( + started.elapsed() < Duration::from_secs(30), + "tardó {:?}; revisa screen.width", + started.elapsed() + ); +} + +#[test] #[ignore = "sale a internet con los dos buscadores; lento"] fn los_dos_buscadores_responden_a_lo_mismo() { // Compara lo que devuelve cada uno para las mismas preguntas. No afirma diff --git a/crates/asist-core/src/config.rs b/crates/asist-core/src/config.rs index 792ad3f..c2a9d55 100644 --- a/crates/asist-core/src/config.rs +++ b/crates/asist-core/src/config.rs @@ -19,6 +19,7 @@ pub struct Config { pub tools: ToolsConfig, pub search: SearchConfig, pub camera: CameraConfig, + pub screen: ScreenConfig, pub supervisor: SupervisorConfig, } @@ -430,6 +431,48 @@ impl Default for CameraConfig { } } +/// Mirar la pantalla. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct ScreenConfig { + pub enabled: bool, + /// Programa de captura. `{ancho}` y `{salida}` se sustituyen antes de + /// ejecutar, y tiene que escribir un JPEG por la salida estándar. Está + /// fuera del binario para que añadir un compositor sea editar un guion. + pub command: Vec<String>, + /// Ancho al que se reduce la captura antes de mandarla al modelo. + /// + /// **No lo bajes a la ligera.** Medido con tipografía de interfaz de 13 px + /// y preguntando por datos concretos: a 1280 px acierta 3 de 3 en 7,6 s; a + /// 960, 2 de 3; a 640, 1 de 3. Y cuando falla no dice que no lo lee: se + /// inventa el contenido con aplomo. + pub width: u32, + /// Monitor concreto. Vacío = todo lo que haya. + pub output: String, + pub timeout_secs: u64, + /// Carpeta donde guardar las capturas. Vacío = no se guarda ninguna, que + /// es lo que corresponde: en una captura caben contraseñas y mensajes + /// privados. + pub save_dir: String, +} + +impl Default for ScreenConfig { + fn default() -> Self { + Self { + enabled: true, + command: vec![ + "scripts/capturar-pantalla.sh".into(), + "{ancho}".into(), + "{salida}".into(), + ], + width: 1280, + output: String::new(), + timeout_secs: 20, + save_dir: String::new(), + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct SupervisorConfig { @@ -569,6 +612,12 @@ impl Config { fix(&mut self.supervisor.tts.binary); fix(&mut self.supervisor.tts.model); fix(&mut self.supervisor.tts.codec); + if let Some(program) = self.screen.command.first_mut() { + let path = PathBuf::from(&*program); + if path.is_relative() && program.contains('/') { + *program = base.join(path).to_string_lossy().into_owned(); + } + } if let Some(program) = self.search.command.first_mut() { let path = PathBuf::from(&*program); if path.is_relative() && program.contains('/') { diff --git a/crates/asist-core/src/lib.rs b/crates/asist-core/src/lib.rs index db3ebfb..87fbc75 100644 --- a/crates/asist-core/src/lib.rs +++ b/crates/asist-core/src/lib.rs @@ -9,6 +9,7 @@ pub mod config; pub mod error; pub mod event; pub mod http; +pub mod proc; pub mod telemetry; pub mod text; pub mod tools; diff --git a/crates/asist-core/src/proc.rs b/crates/asist-core/src/proc.rs new file mode 100644 index 0000000..8e38108 --- /dev/null +++ b/crates/asist-core/src/proc.rs @@ -0,0 +1,228 @@ +//! Ejecutar un programa con un plazo máximo. +//! +//! Tres sitios lo necesitaban con los mismos cuidados —no dejar nunca un +//! proceso colgado, no pasar por una shell, distinguir «falló» de «tardó +//! demasiado»— y cada uno lo tenía escrito a su manera. Aquí está una sola vez. +//! +//! Nada de esto pasa por `sh`: los argumentos van al `execve` tal cual. Importa +//! más de lo que parece, porque lo que acaba en ellos viene, en última +//! instancia, de lo que se ha oído por el micrófono. + +use std::ffi::OsStr; +use std::io::Read; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +/// Cómo terminó. +#[derive(Debug)] +pub struct Output { + pub stdout: Vec<u8>, + pub stderr: String, + pub status: Option<i32>, + pub took: Duration, +} + +impl Output { + pub fn success(&self) -> bool { + self.status == Some(0) + } + + /// Última línea del error, que es donde los programas suelen poner el + /// motivo de verdad. + pub fn last_error_line(&self) -> &str { + self.stderr + .trim() + .lines() + .next_back() + .unwrap_or("sin detalles") + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ProcError { + #[error("no se pudo ejecutar {program}: {source}")] + Spawn { + program: String, + #[source] + source: std::io::Error, + }, + #[error("{program} tardó más de {} s", timeout.as_secs())] + Timeout { program: String, timeout: Duration }, + #[error("fallo esperando a {program}: {source}")] + Wait { + program: String, + #[source] + source: std::io::Error, + }, +} + +/// Lanza `program`, espera hasta `timeout` y devuelve lo que haya escrito. +/// +/// Si vence el plazo, mata el proceso antes de rendirse: un ffmpeg contra una +/// cámara ocupada, o un buscador que no contesta, se quedarían ahí para +/// siempre. +pub fn run( + program: impl AsRef<Path>, + args: impl IntoIterator<Item = impl AsRef<OsStr>>, + timeout: Duration, + working_dir: Option<&Path>, +) -> Result<Output, ProcError> { + let program = program.as_ref(); + let name = program.display().to_string(); + let started = Instant::now(); + + let mut command = Command::new(program); + command + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if let Some(dir) = working_dir { + command.current_dir(dir); + } + + let mut child = command.spawn().map_err(|source| ProcError::Spawn { + program: name.clone(), + source, + })?; + + // Las tuberías se vacían en hilos aparte, y no después de esperar. + // + // Esto no es una optimización: un hijo que escribe más de lo que cabe en el + // búfer de la tubería (64 KB en Linux) se queda bloqueado escribiendo, no + // termina nunca, y el bucle de espera acaba matándolo por plazo vencido + // aunque estuviera haciendo su trabajo. Costó descubrirlo porque los tres + // primeros usos —una fecha, un JSON de búsqueda, un fotograma de 9 KB— + // cabían de sobra; la primera captura de pantalla, de 180 KB, no. + let mut stdout = child.stdout.take(); + let mut stderr = child.stderr.take(); + let stdout_reader = std::thread::spawn(move || { + let mut buf = Vec::new(); + if let Some(pipe) = stdout.as_mut() { + let _ = pipe.read_to_end(&mut buf); + } + buf + }); + let stderr_reader = std::thread::spawn(move || { + let mut buf = Vec::new(); + if let Some(pipe) = stderr.as_mut() { + let _ = pipe.read_to_end(&mut buf); + } + buf + }); + + let deadline = started + timeout; + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if Instant::now() >= deadline => { + // Matar cierra las tuberías, así que los lectores terminan y + // se pueden recoger sin quedarse colgados. + let _ = child.kill(); + let _ = child.wait(); + let _ = stdout_reader.join(); + let _ = stderr_reader.join(); + return Err(ProcError::Timeout { + program: name, + timeout, + }); + } + Ok(None) => std::thread::sleep(Duration::from_millis(20)), + Err(source) => { + return Err(ProcError::Wait { + program: name, + source, + }) + } + } + }; + + let stdout = stdout_reader.join().unwrap_or_default(); + let stderr = stderr_reader.join().unwrap_or_default(); + + Ok(Output { + stdout, + stderr: String::from_utf8_lossy(&stderr).into_owned(), + status: status.code(), + took: started.elapsed(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn se_recoge_la_salida_estandar() { + let out = run("echo", ["hola"], Duration::from_secs(5), None).unwrap(); + assert!(out.success()); + assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hola"); + } + + #[test] + fn un_proceso_colgado_se_mata_al_vencer_el_plazo() { + let started = Instant::now(); + let err = run("sleep", ["30"], Duration::from_millis(300), None).unwrap_err(); + assert!(matches!(err, ProcError::Timeout { .. }), "{err}"); + assert!( + started.elapsed() < Duration::from_secs(3), + "debió cortar enseguida, tardó {:?}", + started.elapsed() + ); + } + + #[test] + fn una_salida_grande_no_bloquea_al_hijo() { + // La regresión que dejó colgada la captura de pantalla: sin vaciar la + // tubería mientras se espera, un hijo que escribe más de 64 KB se + // bloquea y acaba muriendo por plazo vencido. + let out = run( + "dd", + ["if=/dev/zero", "bs=1024", "count=512", "status=none"], + Duration::from_secs(10), + None, + ) + .expect("no debió vencer el plazo"); + assert!(out.success()); + assert_eq!(out.stdout.len(), 512 * 1024); + } + + #[test] + fn el_error_grande_tampoco_bloquea() { + // Mismo problema por la otra tubería: mucho ruido en stderr y poca + // salida es justo lo que hace ffmpeg cuando algo va mal. + let out = run( + "sh", + ["-c", "yes error | head -c 200000 >&2; echo ok"], + Duration::from_secs(10), + None, + ) + .expect("no debió vencer el plazo"); + assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "ok"); + assert!(out.stderr.len() > 100_000); + } + + #[test] + fn un_programa_inexistente_da_un_error_con_su_nombre() { + let err = run("/no/existe/nada", ["x"], Duration::from_secs(1), None).unwrap_err(); + assert!(err.to_string().contains("/no/existe/nada"), "{err}"); + } + + #[test] + fn un_fallo_conserva_el_codigo_y_el_mensaje() { + let out = run("ls", ["/no/existe"], Duration::from_secs(5), None).unwrap(); + assert!(!out.success()); + assert!(!out.last_error_line().is_empty()); + } + + #[test] + fn los_metacaracteres_no_los_interpreta_ninguna_shell() { + let out = run("echo", ["a; echo b"], Duration::from_secs(5), None).unwrap(); + assert_eq!( + String::from_utf8_lossy(&out.stdout).trim(), + "a; echo b", + "el punto y coma debe llegar como texto, no como separador de órdenes" + ); + } +} diff --git a/crates/asist-core/src/tools.rs b/crates/asist-core/src/tools.rs index ac08141..3911985 100644 --- a/crates/asist-core/src/tools.rs +++ b/crates/asist-core/src/tools.rs @@ -177,7 +177,8 @@ impl ToolRegistry { pub mod builtin { use super::*; - use std::process::{Command, Stdio}; + use std::path::Path; + use std::process::Command; /// Fecha y hora locales. Existe porque el modelo no las sabe y las inventa /// con aplomo, y de paso sirve de ejemplo mínimo de herramienta. @@ -361,40 +362,14 @@ pub mod builtin { // Sin shell de por medio: los argumentos van al `execve` tal cual, // así que un «; rm -rf /» es un argumento literal, no otra orden. - let mut command = Command::new(program); - command - .args(&arguments) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - if !self.working_dir.is_empty() { - command.current_dir(&self.working_dir); - } - - let mut child = command.spawn().map_err(|e| fail(e.to_string()))?; - let deadline = std::time::Instant::now() + self.timeout; - loop { - match child.try_wait().map_err(|e| fail(e.to_string()))? { - Some(_) => break, - None if std::time::Instant::now() >= deadline => { - let _ = child.kill(); - let _ = child.wait(); - return Err(fail(format!( - "«{}» superó el plazo de {} s y se detuvo", - rendered.trim(), - self.timeout.as_secs() - ))); - } - None => std::thread::sleep(Duration::from_millis(20)), - } - } + let working_dir = (!self.working_dir.is_empty()).then(|| Path::new(&self.working_dir)); + let output = crate::proc::run(program, &arguments, self.timeout, working_dir) + .map_err(|e| fail(e.to_string()))?; - let output = child.wait_with_output().map_err(|e| fail(e.to_string()))?; let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); let mut body = stdout.trim().to_string(); if body.is_empty() { - body = stderr.trim().to_string(); + body = output.stderr.trim().to_string(); } // La salida se va a leer en voz alta: pasado cierto punto sólo // sirve para aburrir a quien escucha. @@ -402,10 +377,10 @@ pub mod builtin { if body.chars().count() > MAX { body = body.chars().take(MAX).collect::<String>() + "… (salida recortada)"; } - if !output.status.success() { + if !output.success() { return Ok(format!( "la orden terminó con código {}: {body}", - output.status.code().unwrap_or(-1) + output.status.unwrap_or(-1) )); } Ok(if body.is_empty() { @@ -520,7 +495,12 @@ mod tests { let err = shell .call(&json!({ "programa": "sleep", "argumentos": ["30"] })) .unwrap_err(); - assert!(err.to_string().contains("plazo"), "{err}"); + let err = err.to_string(); + assert!(err.contains("tardó más de"), "{err}"); + assert!( + err.contains("sleep"), + "el error debe decir qué orden se colgó: {err}" + ); } #[test] diff --git a/crates/asist-tools/src/camera.rs b/crates/asist-tools/src/camera.rs index fe41319..bdc2aa0 100644 --- a/crates/asist-tools/src/camera.rs +++ b/crates/asist-tools/src/camera.rs @@ -1,56 +1,38 @@ -//! Mirar por la cámara. -//! -//! El servidor ya tiene cargado el proyector multimodal, así que el mismo -//! modelo que conversa puede describir una imagen. La herramienta hace tres -//! cosas: capturar un fotograma, preguntarle al modelo por él y devolver la -//! respuesta como texto. -//! -//! Que la pregunta viaje hasta la cámara importa: «¿de qué color es mi -//! camiseta?» y «¿cuánta gente hay?» necesitan la misma imagen pero -//! descripciones muy distintas, y pedir una descripción genérica para luego -//! interrogarla pierde justo el detalle que se buscaba. -//! -//! La captura no se guarda en disco salvo que se pida expresamente: un -//! asistente que deja fotogramas por ahí es un problema de privacidad, no una -//! comodidad de depuración. +//! La cámara como fuente de imágenes. use std::path::PathBuf; -use std::process::{Command, Stdio}; -use std::sync::Arc; -use std::time::{Duration, Instant}; +use std::time::Duration; use asist_core::error::{Error, Result}; -use asist_core::http::Cancel; -use asist_core::tools::Tool; -use asist_llm::LlmClient; -use serde_json::{json, Value}; -/// Cómo se captura el fotograma. +use crate::vision::{capture_jpeg, FrameSource}; + +const TOOL: &str = "mirar_por_la_camara"; + #[derive(Debug, Clone)] -pub struct CaptureConfig { +pub struct CameraConfig { /// Dispositivo V4L2. pub device: PathBuf, + /// Resolución de captura. Medido con este modelo: 1,3 s a 320x240, 2,9 s a + /// 640x480 y 7,8 s a 1280x720, con la misma descripción útil a partir de + /// 640. Para una escena basta; para leer texto no (eso es la pantalla). pub width: u32, pub height: u32, /// Fotogramas que se descartan antes de quedarse con uno. /// /// La cámara arranca con la exposición automática sin asentar y el primer /// fotograma suele salir quemado. Descartar unos pocos es prácticamente - /// gratis —medido, 0,45 s frente a 0,53 s— y en penumbra se nota. + /// gratis: medido, 0,45 s frente a 0,53 s. pub warmup_frames: u32, - /// Plazo máximo de la captura. pub timeout: Duration, - /// Carpeta donde dejar los fotogramas. Vacío = no se guarda ninguno. + /// Carpeta donde dejar los fotogramas. `None` = no se guarda ninguno. pub save_dir: Option<PathBuf>, } -impl Default for CaptureConfig { +impl Default for CameraConfig { fn default() -> Self { Self { device: PathBuf::from("/dev/video0"), - // 640x480 es el punto de equilibrio medido: el modelo tarda 2,9 s - // y sigue describiendo bien. A 1280x720 tarda 7,8 s, y a 320x240 - // baja a 1,3 s pero deja de distinguir detalles. width: 640, height: 480, warmup_frames: 5, @@ -60,190 +42,87 @@ impl Default for CaptureConfig { } } -/// Captura un fotograma en JPEG. -/// -/// Se apoya en ffmpeg en vez de hablar con V4L2 directamente: una cámara USB -/// entrega MJPEG, YUYV o lo que le parezca, y reimplementar esa negociación -/// para ahorrarse un proceso no sale a cuenta. -pub fn capture(config: &CaptureConfig) -> Result<Vec<u8>> { - let fail = |message: String| Error::Tool { - tool: "mirar_por_la_camara".into(), - message, - }; - - if !config.device.exists() { - return Err(fail(format!( - "no existe el dispositivo {}. Comprueba con «v4l2-ctl --list-devices»", - config.device.display() - ))); - } - - let started = Instant::now(); - let mut command = Command::new("ffmpeg"); - command - .args(["-hide_banner", "-loglevel", "error", "-nostdin"]) - .args(["-f", "v4l2"]) - .args([ - "-video_size", - &format!("{}x{}", config.width, config.height), - ]) - .arg("-i") - .arg(&config.device); - - if config.warmup_frames > 0 { - // Se leen N fotogramas y se conserva el último: es la forma de dejar - // que la exposición se asiente sin abrir el dispositivo dos veces. - command.args(["-vf", &format!("select=eq(n\\,{})", config.warmup_frames)]); - } - command - .args(["-frames:v", "1"]) - .args(["-f", "image2", "-c:v", "mjpeg"]) - .arg("-") - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - - let mut child = command.spawn().map_err(|e| { - fail(format!( - "no se pudo ejecutar ffmpeg ({e}); hace falta para leer la cámara" - )) - })?; - - let deadline = Instant::now() + config.timeout; - loop { - match child.try_wait().map_err(|e| fail(e.to_string()))? { - Some(_) => break, - None if Instant::now() >= deadline => { - let _ = child.kill(); - let _ = child.wait(); - return Err(fail(format!( - "la cámara no respondió en {} s", - config.timeout.as_secs() - ))); - } - None => std::thread::sleep(Duration::from_millis(20)), - } - } - - let output = child.wait_with_output().map_err(|e| fail(e.to_string()))?; - if !output.status.success() || output.stdout.is_empty() { - let stderr = String::from_utf8_lossy(&output.stderr); - let hint = if stderr.contains("Permission denied") { - ". Tu usuario necesita estar en el grupo «video»" - } else if stderr.contains("Device or resource busy") { - ". Otra aplicación está usando la cámara" - } else { - "" - }; - return Err(fail(format!( - "ffmpeg no capturó nada{hint}: {}", - stderr.trim().lines().next_back().unwrap_or("sin detalles") - ))); - } - - if let Some(dir) = &config.save_dir { - // Sólo si se ha pedido: por defecto la imagen no toca el disco. - if let Err(err) = std::fs::create_dir_all(dir).and_then(|()| { - let name = format!( - "frame-{}.jpg", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0) - ); - std::fs::write(dir.join(name), &output.stdout) - }) { - tracing::warn!(target: "camara", %err, "no se pudo guardar el fotograma"); - } - } - - tracing::info!( - target: "camara", - dispositivo = %config.device.display(), - resolucion = format!("{}x{}", config.width, config.height), - kb = output.stdout.len() / 1024, - ms = started.elapsed().as_millis(), - "fotograma captura |