use crate::base::{McpError, ToolDescriptor};
use serde_json::{json, Value};
use std::io::{Read, Write};
use crate::library::exec_with_cleanup;
pub fn python_node_repr(raw: &str) -> Result<String, McpError> {
if raw.is_empty() {
return Err(McpError::Protocol(
"node identifier must not be empty".into(),
));
}
if let Ok(n) = raw.parse::<i32>() {
return Ok(n.to_string());
}
if raw.len() > 16 {
return Err(McpError::Protocol(format!(
"node identifier too long (max 16 chars): '{raw}'"
)));
}
let mut chars = raw.chars();
let first = chars.next().expect("non-empty checked above");
if !first.is_ascii_alphabetic() && first != '_' {
return Err(McpError::Protocol(format!(
"node identifier must start with a letter or underscore: '{raw}'"
)));
}
for ch in chars {
if !ch.is_ascii_alphanumeric() && ch != '_' {
return Err(McpError::Protocol(format!(
"node identifier contains invalid character '{}': '{raw}'",
ch
)));
}
}
Ok(raw.to_string())
}
fn no_args() -> Value {
json!({"type": "object", "properties": {}, "additionalProperties": false})
}
fn two_node_schema() -> Value {
json!({
"type": "object",
"properties": {
"node1": {
"type": "string",
"description": "First node. Row numbers '1'-'60', rail names (TOP_RAIL, BOTTOM_RAIL, GND, DAC0, DAC1), Arduino pins (D0-D13, A0-A7, AREF, RESET), GPIO (GPIO_1-GPIO_8, UART_TX, UART_RX), ADC (ADC0-ADC3, ISENSE_PLUS, ISENSE_MINUS)."
},
"node2": {
"type": "string",
"description": "Second node. Same identifier space as node1."
}
},
"required": ["node1", "node2"],
"additionalProperties": false
})
}
pub fn connect_descriptor() -> ToolDescriptor {
ToolDescriptor::with_timeout(
"connect",
"Create a connection between two nodes on the Jumperless V5 crossbar. \
Nodes may be row numbers 1-60, power rails (TOP_RAIL, BOTTOM_RAIL, GND, \
DAC0, DAC1), Arduino pins (D0-D13, A0-A7, AREF, RESET), GPIO \
(GPIO_1-GPIO_8, UART_TX, UART_RX), or ADC inputs (ADC0-ADC3, \
ISENSE_PLUS, ISENSE_MINUS). The `duplicates` parameter is passed to the \
firmware; -1 (default) allows duplicate connections per firmware convention. \
Firmware validates node names and will error on unknown identifiers. \
Returns {\"connected\": true} on success.",
json!({
"type": "object",
"properties": {
"node1": {
"type": "string",
"description": "First node identifier (row number or named rail/pin)."
},
"node2": {
"type": "string",
"description": "Second node identifier (row number or named rail/pin)."
},
"duplicates": {
"type": "integer",
"description": "Passed to firmware connect(). -1 = allow duplicates (default). Positive values: semantics defined by firmware."
}
},
"required": ["node1", "node2"],
"additionalProperties": false
}),
1_500,
)
}
pub fn disconnect_descriptor() -> ToolDescriptor {
ToolDescriptor::with_timeout(
"disconnect",
"Remove the connection between two nodes on the Jumperless V5 crossbar. \
Both nodes must be valid identifiers (row numbers 1-60, named rail/pin). \
If no connection exists between the two nodes, the firmware treats this \
as a no-op (no error). \
Returns {\"disconnected\": true} on success (no-op if no connection existed).",
two_node_schema(),
1_500,
)
}
pub fn nodes_clear_descriptor() -> ToolDescriptor {
ToolDescriptor::with_timeout(
"nodes_clear",
"DESTRUCTIVE: Remove ALL connections from the Jumperless V5 crossbar immediately. \
This cannot be undone without a saved slot. Strongly recommend calling \
slot_save(7) first to preserve a recovery point. \
Does NOT clear overlays (use overlay_clear_all for that). \
Returns {\"cleared\": true}.",
no_args(),
2_000,
)
}
pub fn is_connected_descriptor() -> ToolDescriptor {
ToolDescriptor::with_timeout(
"is_connected",
"Check whether two nodes on the Jumperless V5 are currently connected through \
the crossbar. Returns {\"connected\": true} or {\"connected\": false}. \
Both node identifiers must be valid (row 1-60, named rail/pin). \
(Internally wraps the device call in bool() because firmware returns a \
ConnectionState class whose string form is 'CONNECTED'/'DISCONNECTED'; \
this tool always returns clean booleans.)",
two_node_schema(),
1_000,
)
}
pub fn descriptors() -> Vec<ToolDescriptor> {
vec![
connect_descriptor(),
disconnect_descriptor(),
nodes_clear_descriptor(),
is_connected_descriptor(),
]
}
pub fn handle_connect<P: Read + Write + ?Sized>(
port: &mut P,
args: &Value,
) -> Result<Value, McpError> {
let n1_raw = args
.get("node1")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError::Protocol("connect: 'node1' argument required".into()))?;
let n2_raw = args
.get("node2")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError::Protocol("connect: 'node2' argument required".into()))?;
let n1 = python_node_repr(n1_raw)
.map_err(|e| McpError::Protocol(format!("connect: node1 invalid — {e}")))?;
let n2 = python_node_repr(n2_raw)
.map_err(|e| McpError::Protocol(format!("connect: node2 invalid — {e}")))?;
let duplicates = match args.get("duplicates") {
Some(v) => v
.as_i64()
.ok_or_else(|| McpError::Protocol("connect: 'duplicates' must be an integer".into()))?,
None => -1,
};
let code = format!("connect({n1}, {n2}, {duplicates})");
exec_with_cleanup(port, &code, "connect")?;
Ok(json!({ "connected": true }))
}
pub fn handle_disconnect<P: Read + Write + ?Sized>(
port: &mut P,
args: &Value,
) -> Result<Value, McpError> {
let n1_raw = args
.get("node1")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError::Protocol("disconnect: 'node1' argument required".into()))?;
let n2_raw = args
.get("node2")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError::Protocol("disconnect: 'node2' argument required".into()))?;
let n1 = python_node_repr(n1_raw)
.map_err(|e| McpError::Protocol(format!("disconnect: node1 invalid — {e}")))?;
let n2 = python_node_repr(n2_raw)
.map_err(|e| McpError::Protocol(format!("disconnect: node2 invalid — {e}")))?;
let code = format!("disconnect({n1}, {n2})");
exec_with_cleanup(port, &code, "disconnect")?;
Ok(json!({ "disconnected": true }))
}
pub fn handle_nodes_clear<P: Read + Write + ?Sized>(port: &mut P) -> Result<Value, McpError> {
exec_with_cleanup(port, "nodes_clear()", "nodes_clear")?;
Ok(json!({ "cleared": true }))
}
pub fn handle_is_connected<P: Read + Write + ?Sized>(
port: &mut P,
args: &Value,
) -> Result<Value, McpError> {
let n1_raw = args
.get("node1")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError::Protocol("is_connected: 'node1' argument required".into()))?;
let n2_raw = args
.get("node2")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError::Protocol("is_connected: 'node2' argument required".into()))?;
let n1 = python_node_repr(n1_raw)
.map_err(|e| McpError::Protocol(format!("is_connected: node1 invalid — {e}")))?;
let n2 = python_node_repr(n2_raw)
.map_err(|e| McpError::Protocol(format!("is_connected: node2 invalid — {e}")))?;
let code = format!("print(bool(is_connected({n1}, {n2})))");
let resp = exec_with_cleanup(port, &code, "is_connected")?;
let trimmed = resp.stdout.trim();
let connected = match trimmed {
"True" => true,
"False" => false,
other => {
return Err(McpError::Protocol(format!(
"is_connected: unexpected device response: '{other}'"
)));
}
};
Ok(json!({ "connected": connected }))
}
#[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_frame() -> Vec<u8> {
b"OK\x04\x04>".to_vec()
}
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 node_repr_integer_row() {
assert_eq!(python_node_repr("5").unwrap(), "5");
assert_eq!(python_node_repr("1").unwrap(), "1");
assert_eq!(python_node_repr("60").unwrap(), "60");
}
#[test]
fn node_repr_named_rail() {
assert_eq!(python_node_repr("TOP_RAIL").unwrap(), "TOP_RAIL");
assert_eq!(python_node_repr("GND").unwrap(), "GND");
assert_eq!(python_node_repr("DAC0").unwrap(), "DAC0");
assert_eq!(python_node_repr("ISENSE_PLUS").unwrap(), "ISENSE_PLUS");
}
#[test]
fn node_repr_arduino_pins() {
assert_eq!(python_node_repr("D13").unwrap(), "D13");
assert_eq!(python_node_repr("A0").unwrap(), "A0");
assert_eq!(python_node_repr("AREF").unwrap(), "AREF");
assert_eq!(python_node_repr("RESET").unwrap(), "RESET");
}
#[test]
fn node_repr_gpio() {
assert_eq!(python_node_repr("GPIO_1").unwrap(), "GPIO_1");
assert_eq!(python_node_repr("UART_TX").unwrap(), "UART_TX");
}
#[test]
fn node_repr_rejects_empty() {
assert!(python_node_repr("").is_err());
}
#[test]
fn node_repr_rejects_injection_attempt() {
assert!(python_node_repr("5;import os").is_err());
assert!(python_node_repr("foo bar").is_err());
assert!(python_node_repr("A0\n").is_err());
assert!(python_node_repr("GND;").is_err());
}
#[test]
fn node_repr_rejects_too_long_identifier() {
let long = "A".repeat(17);
assert!(python_node_repr(&long).is_err());
}
#[test]
fn node_repr_rejects_identifier_starting_with_digit() {
assert!(python_node_repr("5abc").is_err());
}
#[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(&"connect"), "must have 'connect'");
assert!(names.contains(&"disconnect"), "must have 'disconnect'");
assert!(names.contains(&"nodes_clear"), "must have 'nodes_clear'");
assert!(names.contains(&"is_connected"), "must have 'is_connected'");
assert_eq!(descs.len(), 4);
}
#[test]
fn all_descriptors_have_additional_properties_false() {
for d in descriptors() {
assert_eq!(
d.input_schema.get("additionalProperties"),
Some(&Value::Bool(false)),
"descriptor '{}' must have additionalProperties=false",
d.name
);
}
}
#[test]
fn nodes_clear_description_mentions_destructive() {
let d = nodes_clear_descriptor();
assert!(
d.description.to_uppercase().contains("DESTRUCTIVE"),
"nodes_clear description must warn DESTRUCTIVE; got: '{}'",
d.description
);
}
#[test]
fn connect_happy_path_row_to_row() {
let frame = MockPort::ok_frame();
let mut port = MockPort::with_responses(&[&frame]);
let args = json!({"node1": "1", "node2": "5"});
let result = handle_connect(&mut port, &args).unwrap();
assert_eq!(result["connected"], true);
let written = String::from_utf8_lossy(&port.write_data);
assert!(written.contains("connect(1, 5, -1)"), "got: {written}");
}
#[test]
fn connect_happy_path_row_to_rail() {
let frame = MockPort::ok_frame();
let mut port = MockPort::with_responses(&[&frame]);
let args = json!({"node1": "5", "node2": "TOP_RAIL"});
let result = handle_connect(&mut port, &args).unwrap();
assert_eq!(result["connected"], true);
let written = String::from_utf8_lossy(&port.write_data);
assert!(
written.contains("connect(5, TOP_RAIL, -1)"),
"rail must be bare identifier; got: {written}"
);
assert!(
!written.contains("'TOP_RAIL'"),
"rail must not be quoted; got: {written}"
);
}
#[test]
fn connect_explicit_duplicates_arg() {
let frame = MockPort::ok_frame();
let mut port = MockPort::with_responses(&[&frame]);
let args = json!({"node1": "10", "node2": "20", "duplicates": 0});
handle_connect(&mut port, &args).unwrap();
let written = String::from_utf8_lossy(&port.write_data);
assert!(written.contains("connect(10, 20, 0)"), "got: {written}");
}
#[test]
fn connect_rejects_invalid_node() {
let mut port = MockPort::with_responses(&[]);
let args = json!({"node1": "5;import os", "node2": "10"});
let result = handle_connect(&mut port, &args);
assert!(result.is_err(), "injection attempt must be rejected");
}
#[test]
fn connect_device_error_sends_ctrl_c() {
let err = MockPort::error_frame("NameError: connect");
let mut port = MockPort::with_responses(&[&err]);
let args = json!({"node1": "1", "node2": "2"});
let result = handle_connect(&mut port, &args);
assert!(result.is_err());
assert!(
port.write_data.contains(&0x03),
"Ctrl-C must be sent on error"
);
}
#[test]
fn disconnect_happy_path() {
let frame = MockPort::ok_frame();
let mut port = MockPort::with_responses(&[&frame]);
let args = json!({"node1": "1", "node2": "5"});
let result = handle_disconnect(&mut port, &args).unwrap();
assert_eq!(result["disconnected"], true);
let written = String::from_utf8_lossy(&port.write_data);
assert!(written.contains("disconnect(1, 5)"), "got: {written}");
}
#[test]
fn disconnect_rejects_invalid_node() {
let mut port = MockPort::with_responses(&[]);
let args = json!({"node1": "GND", "node2": "bad node"});
let result = handle_disconnect(&mut port, &args);
assert!(result.is_err());
}
#[test]
fn nodes_clear_happy_path() {
let frame = MockPort::ok_frame();
let mut port = MockPort::with_responses(&[&frame]);
let result = handle_nodes_clear(&mut port).unwrap();
assert_eq!(result["cleared"], true);
let written = String::from_utf8_lossy(&port.write_data);
assert!(written.contains("nodes_clear()"), "got: {written}");
}
#[test]
fn nodes_clear_device_error_sends_ctrl_c() {
let err = MockPort::error_frame("RuntimeError: crossbar fault");
let mut port = MockPort::with_responses(&[&err]);
let result = handle_nodes_clear(&mut port);
assert!(result.is_err());
assert!(port.write_data.contains(&0x03));
}
#[test]
fn is_connected_returns_true() {
let frame = MockPort::ok_with_stdout("True");
let mut port = MockPort::with_responses(&[&frame]);
let args = json!({"node1": "1", "node2": "5"});
let result = handle_is_connected(&mut port, &args).unwrap();
assert_eq!(result["connected"], true);
}
#[test]
fn is_connected_returns_false() {
let frame = MockPort::ok_with_stdout("False");
let mut port = MockPort::with_responses(&[&frame]);
let args = json!({"node1": "1", "node2": "60"});
let result = handle_is_connected(&mut port, &args).unwrap();
assert_eq!(result["connected"], false);
}
#[test]
fn is_connected_unexpected_response_is_err() {
let frame = MockPort::ok_with_stdout("maybe");
let mut port = MockPort::with_responses(&[&frame]);
let args = json!({"node1": "1", "node2": "5"});
let result = handle_is_connected(&mut port, &args);
assert!(result.is_err(), "unexpected response must be Err");
match result.unwrap_err() {
McpError::Protocol(msg) => {
assert!(
msg.contains("unexpected"),
"error must say 'unexpected': {msg}"
);
assert!(
msg.contains("maybe"),
"error must include actual value: {msg}"
);
}
other => panic!("expected McpError::Protocol, got: {other:?}"),
}
}
#[test]
fn is_connected_emits_bool_print_call() {
let frame = MockPort::ok_with_stdout("True");
let mut port = MockPort::with_responses(&[&frame]);
let args = json!({"node1": "5", "node2": "GND"});
handle_is_connected(&mut port, &args).unwrap();
let written = String::from_utf8_lossy(&port.write_data);
assert!(
written.contains("print(bool(is_connected(5, GND)))"),
"must use print(bool(...)) wrapper for ConnectionState coercion; got: {written}"
);
}
#[test]
fn is_connected_device_error_sends_ctrl_c() {
let err = MockPort::error_frame("NameError: is_connected");
let mut port = MockPort::with_responses(&[&err]);
let args = json!({"node1": "1", "node2": "2"});
let result = handle_is_connected(&mut port, &args);
assert!(result.is_err());
assert!(port.write_data.contains(&0x03));
}
}