Skip to main content

construct/peripherals/
mod.rs

1//! Hardware peripherals — STM32, RPi GPIO, etc.
2//!
3//! Peripherals extend the agent with physical capabilities. See
4//! `docs/hardware-peripherals-design.md` for the full design.
5
6pub mod traits;
7
8#[cfg(feature = "hardware")]
9pub mod serial;
10
11#[cfg(feature = "hardware")]
12pub mod arduino_flash;
13#[cfg(feature = "hardware")]
14pub mod arduino_upload;
15#[cfg(feature = "hardware")]
16pub mod capabilities_tool;
17#[cfg(feature = "hardware")]
18pub mod nucleo_flash;
19#[cfg(feature = "hardware")]
20pub mod uno_q_bridge;
21#[cfg(feature = "hardware")]
22pub mod uno_q_setup;
23
24#[cfg(all(feature = "peripheral-rpi", target_os = "linux"))]
25pub mod rpi;
26
27#[cfg(any(feature = "hardware", feature = "peripheral-rpi"))]
28pub use traits::Peripheral;
29
30use crate::config::{Config, PeripheralBoardConfig, PeripheralsConfig};
31#[cfg(feature = "hardware")]
32use crate::tools::HardwareMemoryMapTool;
33use crate::tools::Tool;
34use anyhow::Result;
35
36/// List configured boards from config (no connection yet).
37pub fn list_configured_boards(config: &PeripheralsConfig) -> Vec<&PeripheralBoardConfig> {
38    if !config.enabled {
39        return Vec::new();
40    }
41    config.boards.iter().collect()
42}
43
44/// Handle `construct peripheral` subcommands.
45#[allow(clippy::module_name_repetitions)]
46pub async fn handle_command(cmd: crate::PeripheralCommands, config: &Config) -> Result<()> {
47    match cmd {
48        crate::PeripheralCommands::List => {
49            let boards = list_configured_boards(&config.peripherals);
50            if boards.is_empty() {
51                println!("No peripherals configured.");
52                println!();
53                println!("Add one with: construct peripheral add <board> <path>");
54                println!("  Example: construct peripheral add nucleo-f401re /dev/ttyACM0");
55                println!();
56                println!("Or add to config.toml:");
57                println!("  [peripherals]");
58                println!("  enabled = true");
59                println!();
60                println!("  [[peripherals.boards]]");
61                println!("  board = \"nucleo-f401re\"");
62                println!("  transport = \"serial\"");
63                println!("  path = \"/dev/ttyACM0\"");
64            } else {
65                println!("Configured peripherals:");
66                for b in boards {
67                    let path = b.path.as_deref().unwrap_or("(native)");
68                    println!("  {}  {}  {}", b.board, b.transport, path);
69                }
70            }
71        }
72        crate::PeripheralCommands::Add { board, path } => {
73            let transport = if path == "native" { "native" } else { "serial" };
74            let path_opt = if path == "native" {
75                None
76            } else {
77                Some(path.clone())
78            };
79
80            let mut cfg = Box::pin(crate::config::Config::load_or_init()).await?;
81            cfg.peripherals.enabled = true;
82
83            if cfg
84                .peripherals
85                .boards
86                .iter()
87                .any(|b| b.board == board && b.path.as_deref() == path_opt.as_deref())
88            {
89                println!("Board {} at {:?} already configured.", board, path_opt);
90                return Ok(());
91            }
92
93            cfg.peripherals.boards.push(PeripheralBoardConfig {
94                board: board.clone(),
95                transport: transport.to_string(),
96                path: path_opt,
97                baud: 115_200,
98            });
99            cfg.save().await?;
100            println!("Added {} at {}. Restart daemon to apply.", board, path);
101        }
102        #[cfg(feature = "hardware")]
103        crate::PeripheralCommands::Flash { port } => {
104            let port_str = arduino_flash::resolve_port(config, port.as_deref())
105                .or_else(|| port.clone())
106                .ok_or_else(|| anyhow::anyhow!(
107                    "No port specified. Use --port /dev/cu.usbmodem* or add arduino-uno to config.toml"
108                ))?;
109            arduino_flash::flash_arduino_firmware(&port_str)?;
110        }
111        #[cfg(not(feature = "hardware"))]
112        crate::PeripheralCommands::Flash { .. } => {
113            println!("Arduino flash requires the 'hardware' feature.");
114            println!("Build with: cargo build --features hardware");
115        }
116        #[cfg(feature = "hardware")]
117        crate::PeripheralCommands::SetupUnoQ { host } => {
118            uno_q_setup::setup_uno_q_bridge(host.as_deref())?;
119        }
120        #[cfg(not(feature = "hardware"))]
121        crate::PeripheralCommands::SetupUnoQ { .. } => {
122            println!("Uno Q setup requires the 'hardware' feature.");
123            println!("Build with: cargo build --features hardware");
124        }
125        #[cfg(feature = "hardware")]
126        crate::PeripheralCommands::FlashNucleo => {
127            nucleo_flash::flash_nucleo_firmware()?;
128        }
129        #[cfg(not(feature = "hardware"))]
130        crate::PeripheralCommands::FlashNucleo => {
131            println!("Nucleo flash requires the 'hardware' feature.");
132            println!("Build with: cargo build --features hardware");
133        }
134    }
135    Ok(())
136}
137
138/// Create and connect peripherals from config, returning their tools.
139/// Returns empty vec if peripherals disabled or hardware feature off.
140#[cfg(feature = "hardware")]
141pub async fn create_peripheral_tools(config: &PeripheralsConfig) -> Result<Vec<Box<dyn Tool>>> {
142    if !config.enabled || config.boards.is_empty() {
143        return Ok(Vec::new());
144    }
145
146    let mut tools: Vec<Box<dyn Tool>> = Vec::new();
147    let mut serial_transports: Vec<(String, std::sync::Arc<serial::SerialTransport>)> = Vec::new();
148
149    for board in &config.boards {
150        // Arduino Uno Q: Bridge transport (socket to local Bridge app)
151        if board.transport == "bridge" && (board.board == "arduino-uno-q" || board.board == "uno-q")
152        {
153            tools.push(Box::new(uno_q_bridge::UnoQGpioReadTool));
154            tools.push(Box::new(uno_q_bridge::UnoQGpioWriteTool));
155            tracing::info!(board = %board.board, "Uno Q Bridge GPIO tools added");
156            continue;
157        }
158
159        // Native transport: RPi GPIO (Linux only)
160        #[cfg(all(feature = "peripheral-rpi", target_os = "linux"))]
161        if board.transport == "native"
162            && (board.board == "rpi-gpio" || board.board == "raspberry-pi")
163        {
164            match rpi::RpiGpioPeripheral::connect_from_config(board).await {
165                Ok(peripheral) => {
166                    tools.extend(peripheral.tools());
167                    tracing::info!(board = %board.board, "RPi GPIO peripheral connected");
168                }
169                Err(e) => {
170                    tracing::warn!("Failed to connect RPi GPIO {}: {}", board.board, e);
171                }
172            }
173            continue;
174        }
175
176        // Serial transport (STM32, ESP32, Arduino, etc.)
177        if board.transport != "serial" {
178            continue;
179        }
180        if board.path.is_none() {
181            tracing::warn!("Skipping serial board {}: no path", board.board);
182            continue;
183        }
184
185        match serial::SerialPeripheral::connect(board).await {
186            Ok(peripheral) => {
187                let mut p = peripheral;
188                if p.connect().await.is_err() {
189                    tracing::warn!("Peripheral {} connect warning (continuing)", p.name());
190                }
191                serial_transports.push((board.board.clone(), p.transport()));
192                tools.extend(p.tools());
193                if board.board == "arduino-uno" {
194                    if let Some(ref path) = board.path {
195                        tools.push(Box::new(arduino_upload::ArduinoUploadTool::new(
196                            path.clone(),
197                        )));
198                        tracing::info!("Arduino upload tool added (port: {})", path);
199                    }
200                }
201                tracing::info!(board = %board.board, "Serial peripheral connected");
202            }
203            Err(e) => {
204                tracing::warn!("Failed to connect {}: {}", board.board, e);
205            }
206        }
207    }
208
209    // Phase B: Add hardware tools when any boards configured
210    if !tools.is_empty() {
211        let board_names: Vec<String> = config.boards.iter().map(|b| b.board.clone()).collect();
212        tools.push(Box::new(HardwareMemoryMapTool::new(board_names.clone())));
213        tools.push(Box::new(crate::tools::HardwareBoardInfoTool::new(
214            board_names.clone(),
215        )));
216        tools.push(Box::new(crate::tools::HardwareMemoryReadTool::new(
217            board_names,
218        )));
219    }
220
221    // Phase C: Add hardware_capabilities tool when any serial boards
222    if !serial_transports.is_empty() {
223        tools.push(Box::new(capabilities_tool::HardwareCapabilitiesTool::new(
224            serial_transports,
225        )));
226    }
227
228    Ok(tools)
229}
230
231#[cfg(not(feature = "hardware"))]
232#[allow(clippy::unused_async)]
233pub async fn create_peripheral_tools(_config: &PeripheralsConfig) -> Result<Vec<Box<dyn Tool>>> {
234    Ok(Vec::new())
235}
236
237/// Create probe-rs / static board info tools (hardware_board_info, hardware_memory_map,
238/// hardware_memory_read). These use USB/probe-rs or static datasheet data — they never
239/// open a serial port, so they are safe to register regardless of the `hardware` feature.
240#[cfg(feature = "hardware")]
241pub fn create_board_info_tools(config: &PeripheralsConfig) -> Vec<Box<dyn Tool>> {
242    if !config.enabled || config.boards.is_empty() {
243        return Vec::new();
244    }
245    let board_names: Vec<String> = config.boards.iter().map(|b| b.board.clone()).collect();
246    vec![
247        Box::new(crate::tools::HardwareMemoryMapTool::new(
248            board_names.clone(),
249        )),
250        Box::new(crate::tools::HardwareBoardInfoTool::new(
251            board_names.clone(),
252        )),
253        Box::new(crate::tools::HardwareMemoryReadTool::new(board_names)),
254    ]
255}
256
257#[cfg(not(feature = "hardware"))]
258pub fn create_board_info_tools(_config: &PeripheralsConfig) -> Vec<Box<dyn Tool>> {
259    Vec::new()
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use crate::config::{PeripheralBoardConfig, PeripheralsConfig};
266
267    #[test]
268    fn list_configured_boards_when_disabled_returns_empty() {
269        let config = PeripheralsConfig {
270            enabled: false,
271            boards: vec![PeripheralBoardConfig {
272                board: "nucleo-f401re".into(),
273                transport: "serial".into(),
274                path: Some("/dev/ttyACM0".into()),
275                baud: 115_200,
276            }],
277            datasheet_dir: None,
278        };
279        let result = list_configured_boards(&config);
280        assert!(
281            result.is_empty(),
282            "disabled peripherals should return no boards"
283        );
284    }
285
286    #[test]
287    fn list_configured_boards_when_enabled_with_boards() {
288        let config = PeripheralsConfig {
289            enabled: true,
290            boards: vec![
291                PeripheralBoardConfig {
292                    board: "nucleo-f401re".into(),
293                    transport: "serial".into(),
294                    path: Some("/dev/ttyACM0".into()),
295                    baud: 115_200,
296                },
297                PeripheralBoardConfig {
298                    board: "rpi-gpio".into(),
299                    transport: "native".into(),
300                    path: None,
301                    baud: 115_200,
302                },
303            ],
304            datasheet_dir: None,
305        };
306        let result = list_configured_boards(&config);
307        assert_eq!(result.len(), 2);
308        assert_eq!(result[0].board, "nucleo-f401re");
309        assert_eq!(result[1].board, "rpi-gpio");
310    }
311
312    #[test]
313    fn list_configured_boards_when_enabled_but_no_boards() {
314        let config = PeripheralsConfig {
315            enabled: true,
316            boards: vec![],
317            datasheet_dir: None,
318        };
319        let result = list_configured_boards(&config);
320        assert!(
321            result.is_empty(),
322            "enabled with no boards should return empty"
323        );
324    }
325
326    #[tokio::test]
327    async fn create_peripheral_tools_returns_empty_when_disabled() {
328        let config = PeripheralsConfig {
329            enabled: false,
330            boards: vec![],
331            datasheet_dir: None,
332        };
333        let tools = create_peripheral_tools(&config).await.unwrap();
334        assert!(
335            tools.is_empty(),
336            "disabled peripherals should produce no tools"
337        );
338    }
339}