use crate::base::{McpError, ToolDescriptor};
use serde_json::{json, Value};
use std::io::{Read, Write};
use crate::library::exec_with_cleanup;
fn no_args() -> Value {
json!({"type": "object", "properties": {}, "additionalProperties": false})
}
fn parse_probe_pad(raw: &str) -> Result<(Value, String), McpError> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(McpError::Protocol(
"probe: device returned empty response (expected row number or pad name)".into(),
));
}
match trimmed.parse::<i64>() {
Ok(n) => Ok((json!(n), raw.to_string())),
Err(_) => Ok((json!(trimmed), raw.to_string())),
}
}
pub fn probe_read_blocking_descriptor() -> ToolDescriptor {
ToolDescriptor::with_timeout(
"probe_read_blocking",
"BLOCKS up to 60s waiting for user probe touch — do NOT call unless you intend \
to wait for physical user interaction. Waits for the user to touch the Jumperless V5 \
probe tip to a breadboard row or named pad, then returns that location. \
Returns {\"row\": <int or string>, \"raw\": <device string>}. \
`row` is an integer for numbered rows (1-60) or a string for named pads \
(e.g., \"TOP_RAIL\", \"D13_PAD\", \"NO_PAD\"). \
`raw` preserves the exact device output for disambiguation.",
no_args(),
60_000,
)
}
pub fn probe_read_nonblocking_descriptor() -> ToolDescriptor {
ToolDescriptor::with_timeout(
"probe_read_nonblocking",
"Check whether the Jumperless V5 probe tip is currently touching a pad, \
without blocking. Returns immediately. \
Returns {\"row\": <int or string>, \"touched\": bool}. \
When no pad is touched, `row` is -1 and `touched` is false. \
For numbered rows (1-60), `row` is an integer and `touched` is true. \
For named pads (e.g., \"TOP_RAIL\", \"D13_PAD\"), `row` is a string and `touched` is true.",
no_args(),
1_000,
)
}
pub fn probe_button_descriptor() -> ToolDescriptor {
ToolDescriptor::with_timeout(
"probe_button",
"Read the current state of the physical buttons on the Jumperless V5 probe handle. \
Returns immediately (non-blocking). \
Returns {\"button\": \"CONNECT\"} when the front button is pressed, \
{\"button\": \"REMOVE\"} when the rear button is pressed, or \
{\"button\": \"NONE\"} when no button is pressed. \
Any response outside these three values is an error.",
no_args(),
1_000,
)
}
pub fn descriptors() -> Vec<ToolDescriptor> {
vec![
probe_read_blocking_descriptor(),
probe_read_nonblocking_descriptor(),
probe_button_descriptor(),
]
}
pub fn handle_probe_read_blocking<P: Read + Write + ?Sized>(
port: &mut P,
) -> Result<Value, McpError> {
let code = "print(probe_read_blocking())";
let resp = exec_with_cleanup(port, code, "probe_read_blocking")?;
let raw = resp.stdout.trim().to_string();
let (row, _) = parse_probe_pad(&raw)?;
Ok(json!({ "row": row, "raw": raw }))
}
pub fn handle_probe_read_nonblocking<P: Read + Write + ?Sized>(
port: &mut P,
) -> Result<Value, McpError> {
let code = "print(probe_read_nonblocking())";
let resp = exec_with_cleanup(port, code, "probe_read_nonblocking")?;
let raw = resp.stdout.trim().to_string();
let (row, _) = parse_probe_pad(&raw)?;
let touched = row != json!(-1_i64);
Ok(json!({ "row": row, "touched": touched }))
}
pub fn handle_probe_button<P: Read + Write + ?Sized>(port: &mut P) -> Result<Value, McpError> {
let code = "print(probe_button(blocking=False))";
let resp = exec_with_cleanup(port, code, "probe_button")?;
let trimmed = resp.stdout.trim().to_string();
match trimmed.as_str() {
"CONNECT" | "REMOVE" | "NONE" => Ok(json!({ "button": trimmed })),
other => Err(McpError::Protocol(format!(
"probe_button: unexpected device response: '{other}' \
(expected CONNECT, REMOVE, or NONE)"
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::VecDeque;
use std::io::{self, Read, Write};
struct MockPort {
read_data: VecDeque<u8>,
pub write_data: Vec<u8>,
}
impl MockPort {
fn with_responses(responses: &[&[u8]]) -> Self {
let mut buf = Vec::new();
for r in responses {
buf.extend_from_slice(r);
}
MockPort {
read_data: VecDeque::from(buf),
write_data: Vec::new(),
}
}
fn ok_with_stdout(line: &str) -> Vec<u8> {
let mut v = b"OK".to_vec();
v.extend_from_slice(line.as_bytes());
v.push(b'\n');
v.extend_from_slice(b"\x04\x04>");
v
}
fn error_frame(msg: &str) -> Vec<u8> {
let mut v = b"OK\x04".to_vec();
v.extend_from_slice(msg.as_bytes());
v.push(b'\n');
v.push(b'\x04');
v.push(b'>');
v
}
}
impl Read for MockPort {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
let n = buf.len().min(self.read_data.len());
if n == 0 {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"MockPort: no more scripted bytes",
));
}
for (dst, src) in buf[..n].iter_mut().zip(self.read_data.drain(..n)) {
*dst = src;
}
Ok(n)
}
}
impl Write for MockPort {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.write_data.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[test]
fn all_descriptors_have_correct_names() {
let descs = descriptors();
let names: Vec<&str> = descs.iter().map(|d| d.name.as_str()).collect();
assert!(names.contains(&"probe_read_blocking"));
assert!(names.contains(&"probe_read_nonblocking"));
assert!(names.contains(&"probe_button"));
assert_eq!(descs.len(), 3);
}
#[test]
fn all_descriptors_have_object_schema_with_additional_properties_false() {
for d in descriptors() {
assert!(
matches!(d.input_schema, Value::Object(_)),
"descriptor '{}' must have object input_schema",
d.name
);
assert_eq!(
d.input_schema.get("additionalProperties"),
Some(&Value::Bool(false)),
"descriptor '{}' must have additionalProperties=false",
d.name
);
}
}
#[test]
fn probe_read_blocking_description_warns_about_blocking() {
let d = probe_read_blocking_descriptor();
let desc_upper = d.description.to_uppercase();
assert!(
desc_upper.contains("BLOCKS"),
"probe_read_blocking description must contain 'BLOCKS'; got: {}",
d.description
);
assert!(
d.description.contains("60"),
"probe_read_blocking description must mention 60s timeout; got: {}",
d.description
);
}
#[test]
fn probe_read_blocking_integer_row() {
let frame = MockPort::ok_with_stdout("42");
let mut port = MockPort::with_responses(&[&frame]);
let result = handle_probe_read_blocking(&mut port).unwrap();
assert_eq!(result["row"], json!(42_i64));
assert_eq!(result["raw"], json!("42"));
}
#[test]
fn probe_read_blocking_string_node_name() {
let frame = MockPort::ok_with_stdout("TOP_RAIL");
let mut port = MockPort::with_responses(&[&frame]);
let result = handle_probe_read_blocking(&mut port).unwrap();
assert_eq!(result["row"], json!("TOP_RAIL"));
assert_eq!(result["raw"], json!("TOP_RAIL"));
}
#[test]
fn probe_read_nonblocking_integer_row_touched_true() {
let frame = MockPort::ok_with_stdout("5");
let mut port = MockPort::with_responses(&[&frame]);
let result = handle_probe_read_nonblocking(&mut port).unwrap();
assert_eq!(result["row"], json!(5_i64));
assert_eq!(result["touched"], json!(true));
}
#[test]
fn probe_read_nonblocking_minus_one_touched_false() {
let frame = MockPort::ok_with_stdout("-1");
let mut port = MockPort::with_responses(&[&frame]);
let result = handle_probe_read_nonblocking(&mut port).unwrap();
assert_eq!(result["row"], json!(-1_i64));
assert_eq!(result["touched"], json!(false));
}
#[test]
fn probe_read_nonblocking_string_node_name_touched_true() {
let frame = MockPort::ok_with_stdout("D7");
let mut port = MockPort::with_responses(&[&frame]);
let result = handle_probe_read_nonblocking(&mut port).unwrap();
assert_eq!(result["row"], json!("D7"));
assert_eq!(result["touched"], json!(true));
}
#[test]
fn probe_button_connect() {
let frame = MockPort::ok_with_stdout("CONNECT");
let mut port = MockPort::with_responses(&[&frame]);
let result = handle_probe_button(&mut port).unwrap();
assert_eq!(result["button"], json!("CONNECT"));
}
#[test]
fn probe_button_remove() {
let frame = MockPort::ok_with_stdout("REMOVE");
let mut port = MockPort::with_responses(&[&frame]);
let result = handle_probe_button(&mut port).unwrap();
assert_eq!(result["button"], json!("REMOVE"));
}
#[test]
fn probe_button_none() {
let frame = MockPort::ok_with_stdout("NONE");
let mut port = MockPort::with_responses(&[&frame]);
let result = handle_probe_button(&mut port).unwrap();
assert_eq!(result["button"], json!("NONE"));
}
#[test]
fn probe_read_blocking_empty_response_returns_err() {
let frame = MockPort::ok_with_stdout("");
let mut port = MockPort::with_responses(&[&frame]);
let result = handle_probe_read_blocking(&mut port);
assert!(result.is_err(), "empty device response must Err");
match result.unwrap_err() {
McpError::Protocol(msg) => assert!(
msg.contains("empty"),
"error must mention empty response; got: {msg}"
),
other => panic!("expected Protocol err, got: {other:?}"),
}
}
#[test]
fn probe_read_nonblocking_empty_response_returns_err() {
let frame = MockPort::ok_with_stdout("");
let mut port = MockPort::with_responses(&[&frame]);
let result = handle_probe_read_nonblocking(&mut port);
assert!(
result.is_err(),
"empty device response must Err (was producing phantom touched=true)"
);
}
#[test]
fn probe_button_unexpected_value_returns_err() {
let frame = MockPort::ok_with_stdout("CONNECT_BUTTON");
let mut port = MockPort::with_responses(&[&frame]);
let result = handle_probe_button(&mut port);
assert!(result.is_err(), "unexpected device output must return Err");
match result.unwrap_err() {
McpError::Protocol(msg) => {
assert!(
msg.contains("unexpected"),
"error must describe unexpected value; got: {msg}"
);
assert!(
msg.contains("CONNECT_BUTTON"),
"error must include the actual value; got: {msg}"
);
}
other => panic!("expected McpError::Protocol, got: {other:?}"),
}
}
#[test]
fn probe_button_empty_response_returns_err() {
let frame = MockPort::ok_with_stdout("");
let mut port = MockPort::with_responses(&[&frame]);
let result = handle_probe_button(&mut port);
assert!(
result.is_err(),
"empty device output must return Err for probe_button"
);
}
#[test]
fn probe_button_device_error_sends_ctrl_c() {
let err = MockPort::error_frame("NameError: probe_button");
let mut port = MockPort::with_responses(&[&err]);
let result = handle_probe_button(&mut port);
assert!(result.is_err());
assert!(port.write_data.contains(&0x03));
}
}