Skip to main content

zeroclaw_hardware/
gpio.rs

1//! GPIO tools — `gpio_read` and `gpio_write` for LLM-driven hardware control.
2//!
3//! These are the first built-in hardware tools. They implement the standard
4//! [`Tool`] trait so the LLM can call them via function
5//! calling, and dispatch commands to physical devices via the
6//! `Transport` layer.
7//!
8//! Wire protocol (ZeroClaw serial JSON):
9//! ```text
10//! gpio_write:
11//!   Host → Device:  {"cmd":"gpio_write","params":{"pin":25,"value":1}}\n
12//!   Device → Host:  {"ok":true,"data":{"pin":25,"value":1,"state":"HIGH"}}\n
13//!
14//! gpio_read:
15//!   Host → Device:  {"cmd":"gpio_read","params":{"pin":25}}\n
16//!   Device → Host:  {"ok":true,"data":{"pin":25,"value":1,"state":"HIGH"}}\n
17//! ```
18
19use super::device::DeviceRegistry;
20use super::protocol::ZcCommand;
21use async_trait::async_trait;
22use serde_json::json;
23use std::sync::Arc;
24use tokio::sync::RwLock;
25use zeroclaw_api::attribution::ToolKind;
26use zeroclaw_api::tool::{Tool, ToolResult};
27use zeroclaw_api::tool_attribution;
28
29tool_attribution!(GpioWriteTool, ToolKind::Plugin);
30tool_attribution!(GpioReadTool, ToolKind::Plugin);
31
32// ── GpioWriteTool ─────────────────────────────────────────────────────────────
33
34/// Tool: set a GPIO pin HIGH or LOW on a connected hardware device.
35///
36/// The LLM provides `device` (alias), `pin`, and `value` (0 or 1).
37/// The tool builds a `ZcCommand`, sends it via the device's transport,
38/// and returns a human-readable result.
39pub struct GpioWriteTool {
40    registry: Arc<RwLock<DeviceRegistry>>,
41}
42
43impl GpioWriteTool {
44    pub fn new(registry: Arc<RwLock<DeviceRegistry>>) -> Self {
45        Self { registry }
46    }
47}
48
49#[async_trait]
50impl Tool for GpioWriteTool {
51    fn name(&self) -> &str {
52        "gpio_write"
53    }
54
55    fn description(&self) -> &str {
56        "Set a GPIO pin HIGH (1) or LOW (0) on a connected hardware device"
57    }
58
59    fn parameters_schema(&self) -> serde_json::Value {
60        json!({
61            "type": "object",
62            "properties": {
63                "device": {
64                    "type": "string",
65                    "description": "Device alias e.g. pico0, arduino0"
66                },
67                "pin": {
68                    "type": "integer",
69                    "description": "GPIO pin number"
70                },
71                "value": {
72                    "type": "integer",
73                    "enum": [0, 1],
74                    "description": "1 = HIGH (on), 0 = LOW (off)"
75                }
76            },
77            "required": ["pin", "value"]
78        })
79    }
80
81    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
82        let pin = match args.get("pin").and_then(|v| v.as_u64()) {
83            Some(p) => p,
84            None => {
85                return Ok(ToolResult {
86                    success: false,
87                    output: String::new(),
88                    error: Some("missing required parameter: pin".to_string()),
89                });
90            }
91        };
92        let value = match args.get("value").and_then(|v| v.as_u64()) {
93            Some(v) => v,
94            None => {
95                return Ok(ToolResult {
96                    success: false,
97                    output: String::new(),
98                    error: Some("missing required parameter: value".to_string()),
99                });
100            }
101        };
102
103        if value > 1 {
104            return Ok(ToolResult {
105                success: false,
106                output: String::new(),
107                error: Some("value must be 0 or 1".to_string()),
108            });
109        }
110
111        // Resolve device alias and obtain an owned context (Arc-based) before
112        // dropping the registry read guard — avoids holding the lock across async I/O.
113        let (device_alias, ctx) = {
114            let registry = self.registry.read().await;
115            match registry.resolve_gpio_device(&args) {
116                Ok(resolved) => resolved,
117                Err(msg) => {
118                    return Ok(ToolResult {
119                        success: false,
120                        output: String::new(),
121                        error: Some(msg),
122                    });
123                }
124            }
125            // registry read guard dropped here
126        };
127
128        let cmd = ZcCommand::new("gpio_write", json!({ "pin": pin, "value": value }));
129
130        match ctx.transport.send(&cmd).await {
131            Ok(resp) if resp.ok => {
132                let state = resp
133                    .data
134                    .get("state")
135                    .and_then(|v| v.as_str())
136                    .unwrap_or(if value == 1 { "HIGH" } else { "LOW" });
137                Ok(ToolResult {
138                    success: true,
139                    output: format!("GPIO {} set {} on {}", pin, state, device_alias),
140                    error: None,
141                })
142            }
143            Ok(resp) => Ok(ToolResult {
144                success: false,
145                output: String::new(),
146                error: Some(
147                    resp.error
148                        .unwrap_or_else(|| "device returned ok:false".to_string()),
149                ),
150            }),
151            Err(e) => Ok(ToolResult {
152                success: false,
153                output: String::new(),
154                error: Some(format!("transport error: {}", e)),
155            }),
156        }
157    }
158}
159
160// ── GpioReadTool ──────────────────────────────────────────────────────────────
161
162/// Tool: read the current HIGH/LOW state of a GPIO pin on a connected device.
163///
164/// The LLM provides `device` (alias) and `pin`. The tool builds a `ZcCommand`,
165/// sends it via the device's transport, and returns the pin state.
166pub struct GpioReadTool {
167    registry: Arc<RwLock<DeviceRegistry>>,
168}
169
170impl GpioReadTool {
171    pub fn new(registry: Arc<RwLock<DeviceRegistry>>) -> Self {
172        Self { registry }
173    }
174}
175
176#[async_trait]
177impl Tool for GpioReadTool {
178    fn name(&self) -> &str {
179        "gpio_read"
180    }
181
182    fn description(&self) -> &str {
183        "Read the current HIGH/LOW state of a GPIO pin on a connected device"
184    }
185
186    fn parameters_schema(&self) -> serde_json::Value {
187        json!({
188            "type": "object",
189            "properties": {
190                "device": {
191                    "type": "string",
192                    "description": "Device alias e.g. pico0, arduino0"
193                },
194                "pin": {
195                    "type": "integer",
196                    "description": "GPIO pin number to read"
197                }
198            },
199            "required": ["pin"]
200        })
201    }
202
203    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
204        let pin = match args.get("pin").and_then(|v| v.as_u64()) {
205            Some(p) => p,
206            None => {
207                return Ok(ToolResult {
208                    success: false,
209                    output: String::new(),
210                    error: Some("missing required parameter: pin".to_string()),
211                });
212            }
213        };
214
215        // Resolve device alias and obtain an owned context (Arc-based) before
216        // dropping the registry read guard — avoids holding the lock across async I/O.
217        let (device_alias, ctx) = {
218            let registry = self.registry.read().await;
219            match registry.resolve_gpio_device(&args) {
220                Ok(resolved) => resolved,
221                Err(msg) => {
222                    return Ok(ToolResult {
223                        success: false,
224                        output: String::new(),
225                        error: Some(msg),
226                    });
227                }
228            }
229            // registry read guard dropped here
230        };
231
232        let cmd = ZcCommand::new("gpio_read", json!({ "pin": pin }));
233
234        match ctx.transport.send(&cmd).await {
235            Ok(resp) if resp.ok => {
236                let value = resp.data.get("value").and_then(|v| v.as_u64()).unwrap_or(0);
237                let state = resp
238                    .data
239                    .get("state")
240                    .and_then(|v| v.as_str())
241                    .unwrap_or(if value == 1 { "HIGH" } else { "LOW" });
242                Ok(ToolResult {
243                    success: true,
244                    output: format!("GPIO {} is {} ({}) on {}", pin, state, value, device_alias),
245                    error: None,
246                })
247            }
248            Ok(resp) => Ok(ToolResult {
249                success: false,
250                output: String::new(),
251                error: Some(
252                    resp.error
253                        .unwrap_or_else(|| "device returned ok:false".to_string()),
254                ),
255            }),
256            Err(e) => Ok(ToolResult {
257                success: false,
258                output: String::new(),
259                error: Some(format!("transport error: {}", e)),
260            }),
261        }
262    }
263}
264
265// ── Factory ───────────────────────────────────────────────────────────────────
266
267/// Create the built-in GPIO tools for a given device registry.
268///
269/// Returns `[GpioWriteTool, GpioReadTool]` ready for registration in the
270/// agent's tool list or a future `ToolRegistry`.
271pub fn gpio_tools(registry: Arc<RwLock<DeviceRegistry>>) -> Vec<Box<dyn Tool>> {
272    vec![
273        Box::new(GpioWriteTool::new(registry.clone())),
274        Box::new(GpioReadTool::new(registry)),
275    ]
276}
277
278// ── Tests ─────────────────────────────────────────────────────────────────────
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use crate::{
284        device::{DeviceCapabilities, DeviceRegistry},
285        protocol::ZcResponse,
286        transport::{Transport, TransportError, TransportKind},
287    };
288    use std::sync::atomic::{AtomicBool, Ordering};
289
290    /// Mock transport that returns configurable responses.
291    struct MockTransport {
292        response: tokio::sync::Mutex<ZcResponse>,
293        connected: AtomicBool,
294        last_cmd: tokio::sync::Mutex<Option<ZcCommand>>,
295    }
296
297    impl MockTransport {
298        fn new(response: ZcResponse) -> Self {
299            Self {
300                response: tokio::sync::Mutex::new(response),
301                connected: AtomicBool::new(true),
302                last_cmd: tokio::sync::Mutex::new(None),
303            }
304        }
305
306        fn disconnected() -> Self {
307            let t = Self::new(ZcResponse::error("mock: disconnected"));
308            t.connected.store(false, Ordering::SeqCst);
309            t
310        }
311
312        async fn last_command(&self) -> Option<ZcCommand> {
313            self.last_cmd.lock().await.clone()
314        }
315    }
316
317    #[async_trait]
318    impl Transport for MockTransport {
319        async fn send(&self, cmd: &ZcCommand) -> Result<ZcResponse, TransportError> {
320            if !self.connected.load(Ordering::SeqCst) {
321                return Err(TransportError::Disconnected);
322            }
323            *self.last_cmd.lock().await = Some(cmd.clone());
324            Ok(self.response.lock().await.clone())
325        }
326
327        fn kind(&self) -> TransportKind {
328            TransportKind::Serial
329        }
330
331        fn is_connected(&self) -> bool {
332            self.connected.load(Ordering::SeqCst)
333        }
334    }
335
336    /// Helper: build a registry with one device + mock transport.
337    fn registry_with_mock(transport: Arc<MockTransport>) -> Arc<RwLock<DeviceRegistry>> {
338        let mut reg = DeviceRegistry::new();
339        let alias = reg.register(
340            "raspberry-pi-pico",
341            Some(0x2e8a),
342            Some(0x000a),
343            Some("/dev/ttyACM0".to_string()),
344            Some("ARM Cortex-M0+".to_string()),
345        );
346        reg.attach_transport(
347            &alias,
348            transport as Arc<dyn Transport>,
349            DeviceCapabilities {
350                gpio: true,
351                ..Default::default()
352            },
353        )
354        .expect("alias was just registered");
355        Arc::new(RwLock::new(reg))
356    }
357
358    // ── GpioWriteTool tests ──────────────────────────────────────────────
359
360    #[tokio::test]
361    async fn gpio_write_success() {
362        let mock = Arc::new(MockTransport::new(ZcResponse::success(
363            json!({"pin": 25, "value": 1, "state": "HIGH"}),
364        )));
365        let reg = registry_with_mock(mock.clone());
366        let tool = GpioWriteTool::new(reg);
367
368        let result = tool
369            .execute(json!({"device": "pico0", "pin": 25, "value": 1}))
370            .await
371            .unwrap();
372
373        assert!(result.success);
374        assert_eq!(result.output, "GPIO 25 set HIGH on pico0");
375        assert!(result.error.is_none());
376
377        // Verify the command sent to the device
378        let cmd = mock.last_command().await.unwrap();
379        assert_eq!(cmd.cmd, "gpio_write");
380        assert_eq!(cmd.params["pin"], 25);
381        assert_eq!(cmd.params["value"], 1);
382    }
383
384    #[tokio::test]
385    async fn gpio_write_low() {
386        let mock = Arc::new(MockTransport::new(ZcResponse::success(
387            json!({"pin": 13, "value": 0, "state": "LOW"}),
388        )));
389        let reg = registry_with_mock(mock.clone());
390        let tool = GpioWriteTool::new(reg);
391
392        let result = tool
393            .execute(json!({"device": "pico0", "pin": 13, "value": 0}))
394            .await
395            .unwrap();
396
397        assert!(result.success);
398        assert_eq!(result.output, "GPIO 13 set LOW on pico0");
399    }
400
401    #[tokio::test]
402    async fn gpio_write_device_error() {
403        let mock = Arc::new(MockTransport::new(ZcResponse::error(
404            "pin 99 not available",
405        )));
406        let reg = registry_with_mock(mock);
407        let tool = GpioWriteTool::new(reg);
408
409        let result = tool
410            .execute(json!({"device": "pico0", "pin": 99, "value": 1}))
411            .await
412            .unwrap();
413
414        assert!(!result.success);
415        assert_eq!(result.error.as_deref(), Some("pin 99 not available"));
416    }
417
418    #[tokio::test]
419    async fn gpio_write_transport_disconnected() {
420        let mock = Arc::new(MockTransport::disconnected());
421        let reg = registry_with_mock(mock);
422        let tool = GpioWriteTool::new(reg);
423
424        let result = tool
425            .execute(json!({"device": "pico0", "pin": 25, "value": 1}))
426            .await
427            .unwrap();
428
429        assert!(!result.success);
430        assert!(result.error.as_deref().unwrap().contains("transport"));
431    }
432
433    #[tokio::test]
434    async fn gpio_write_unknown_device() {
435        let mock = Arc::new(MockTransport::new(ZcResponse::success(json!({}))));
436        let reg = registry_with_mock(mock);
437        let tool = GpioWriteTool::new(reg);
438
439        let result = tool
440            .execute(json!({"device": "nonexistent", "pin": 25, "value": 1}))
441            .await
442            .unwrap();
443
444        assert!(!result.success);
445        assert!(result.error.as_deref().unwrap().contains("not found"));
446    }
447
448    #[tokio::test]
449    async fn gpio_write_invalid_value() {
450        let mock = Arc::new(MockTransport::new(ZcResponse::success(json!({}))));
451        let reg = registry_with_mock(mock);
452        let tool = GpioWriteTool::new(reg);
453
454        let result = tool
455            .execute(json!({"device": "pico0", "pin": 25, "value": 5}))
456            .await
457            .unwrap();
458
459        assert!(!result.success);
460        assert_eq!(result.error.as_deref(), Some("value must be 0 or 1"));
461    }
462
463    #[tokio::test]
464    async fn gpio_write_missing_params() {
465        let mock = Arc::new(MockTransport::new(ZcResponse::success(json!({}))));
466        let reg = registry_with_mock(mock);
467        let tool = GpioWriteTool::new(reg);
468
469        // Missing pin
470        let result = tool
471            .execute(json!({"device": "pico0", "value": 1}))
472            .await
473            .unwrap();
474        assert!(!result.success);
475        assert!(
476            result
477                .error
478                .as_deref()
479                .unwrap_or("")
480                .contains("missing required parameter: pin")
481        );
482
483        // Missing device with empty registry — auto-select finds no GPIO device → Ok(failure)
484        let empty_reg = Arc::new(RwLock::new(DeviceRegistry::new()));
485        let tool_no_reg = GpioWriteTool::new(empty_reg);
486        let result = tool_no_reg
487            .execute(json!({"pin": 25, "value": 1}))
488            .await
489            .unwrap();
490        assert!(!result.success);
491        assert!(result.error.as_deref().unwrap_or("").contains("no GPIO"));
492
493        // Missing value
494        let result = tool
495            .execute(json!({"device": "pico0", "pin": 25}))
496            .await
497            .unwrap();
498        assert!(!result.success);
499        assert!(
500            result
501                .error
502                .as_deref()
503                .unwrap_or("")
504                .contains("missing required parameter: value")
505        );
506    }
507
508    // ── GpioReadTool tests ───────────────────────────────────────────────
509
510    #[tokio::test]
511    async fn gpio_read_success() {
512        let mock = Arc::new(MockTransport::new(ZcResponse::success(
513            json!({"pin": 25, "value": 1, "state": "HIGH"}),
514        )));
515        let reg = registry_with_mock(mock.clone());
516        let tool = GpioReadTool::new(reg);
517
518        let result = tool
519            .execute(json!({"device": "pico0", "pin": 25}))
520            .await
521            .unwrap();
522
523        assert!(result.success);
524        assert_eq!(result.output, "GPIO 25 is HIGH (1) on pico0");
525        assert!(result.error.is_none());
526
527        let cmd = mock.last_command().await.unwrap();
528        assert_eq!(cmd.cmd, "gpio_read");
529        assert_eq!(cmd.params["pin"], 25);
530    }
531
532    #[tokio::test]
533    async fn gpio_read_low() {
534        let mock = Arc::new(MockTransport::new(ZcResponse::success(
535            json!({"pin": 13, "value": 0, "state": "LOW"}),
536        )));
537        let reg = registry_with_mock(mock);
538        let tool = GpioReadTool::new(reg);
539
540        let result = tool
541            .execute(json!({"device": "pico0", "pin": 13}))
542            .await
543            .unwrap();
544
545        assert!(result.success);
546        assert_eq!(result.output, "GPIO 13 is LOW (0) on pico0");
547    }
548
549    #[tokio::test]
550    async fn gpio_read_device_error() {
551        let mock = Arc::new(MockTransport::new(ZcResponse::error("pin not configured")));
552        let reg = registry_with_mock(mock);
553        let tool = GpioReadTool::new(reg);
554
555        let result = tool
556            .execute(json!({"device": "pico0", "pin": 99}))
557            .await
558            .unwrap();
559
560        assert!(!result.success);
561        assert_eq!(result.error.as_deref(), Some("pin not configured"));
562    }
563
564    #[tokio::test]
565    async fn gpio_read_transport_disconnected() {
566        let mock = Arc::new(MockTransport::disconnected());
567        let reg = registry_with_mock(mock);
568        let tool = GpioReadTool::new(reg);
569
570        let result = tool
571            .execute(json!({"device": "pico0", "pin": 25}))
572            .await
573            .unwrap();
574
575        assert!(!result.success);
576        assert!(result.error.as_deref().unwrap().contains("transport"));
577    }
578
579    #[tokio::test]
580    async fn gpio_read_missing_params() {
581        let mock = Arc::new(MockTransport::new(ZcResponse::success(json!({}))));
582        let reg = registry_with_mock(mock);
583        let tool = GpioReadTool::new(reg);
584
585        // Missing pin
586        let result = tool.execute(json!({"device": "pico0"})).await.unwrap();
587        assert!(!result.success);
588        assert!(
589            result
590                .error
591                .as_deref()
592                .unwrap_or("")
593                .contains("missing required parameter: pin")
594        );
595
596        // Missing device with empty registry — auto-select finds no GPIO device → Ok(failure)
597        let empty_reg = Arc::new(RwLock::new(DeviceRegistry::new()));
598        let tool_no_reg = GpioReadTool::new(empty_reg);
599        let result = tool_no_reg.execute(json!({"pin": 25})).await.unwrap();
600        assert!(!result.success);
601        assert!(result.error.as_deref().unwrap_or("").contains("no GPIO"));
602    }
603
604    // ── Factory / spec tests ─────────────────────────────────────────────
605
606    #[test]
607    fn gpio_tools_factory_returns_two() {
608        let reg = Arc::new(RwLock::new(DeviceRegistry::new()));
609        let tools = gpio_tools(reg);
610        assert_eq!(tools.len(), 2);
611        assert_eq!(tools[0].name(), "gpio_write");
612        assert_eq!(tools[1].name(), "gpio_read");
613    }
614
615    #[test]
616    fn gpio_write_spec_is_valid() {
617        let reg = Arc::new(RwLock::new(DeviceRegistry::new()));
618        let tool = GpioWriteTool::new(reg);
619        let spec = tool.spec();
620        assert_eq!(spec.name, "gpio_write");
621        assert!(spec.parameters["properties"]["device"].is_object());
622        assert!(spec.parameters["properties"]["pin"].is_object());
623        assert!(spec.parameters["properties"]["value"].is_object());
624        let required = spec.parameters["required"].as_array().unwrap();
625        assert_eq!(required.len(), 2, "required should be [pin, value]");
626    }
627
628    #[test]
629    fn gpio_read_spec_is_valid() {
630        let reg = Arc::new(RwLock::new(DeviceRegistry::new()));
631        let tool = GpioReadTool::new(reg);
632        let spec = tool.spec();
633        assert_eq!(spec.name, "gpio_read");
634        assert!(spec.parameters["properties"]["device"].is_object());
635        assert!(spec.parameters["properties"]["pin"].is_object());
636        let required = spec.parameters["required"].as_array().unwrap();
637        assert_eq!(required.len(), 1, "required should be [pin]");
638    }
639}