use crate::base::{McpError, ToolDescriptor};
use serde_json::{json, Value};
use std::io::{Read, Write};
use crate::library::exec_with_cleanup;
fn validate_sensor(v: &Value) -> Result<i64, McpError> {
let i = v
.as_i64()
.ok_or_else(|| McpError::Protocol("sensor must be an integer (0 or 1)".into()))?;
if !(0..=1).contains(&i) {
return Err(McpError::Protocol(format!(
"sensor must be 0 (DAC0/Probe) or 1 (TOP_RAIL); got {i}"
)));
}
Ok(i)
}
pub fn ina_get_current_descriptor() -> ToolDescriptor {
ToolDescriptor::with_timeout(
"ina_get_current",
"Read the current in Amps from an INA219 current sensor on the Jumperless V5. \
sensor: 0 = DAC0/Probe rail, 1 = TOP_RAIL. \
Returns {\"current_amps\": float, \"sensor\": int}.",
json!({
"type": "object",
"properties": {
"sensor": {
"type": "integer",
"minimum": 0,
"maximum": 1,
"description": "INA sensor index: 0 = DAC0/Probe, 1 = TOP_RAIL."
}
},
"required": ["sensor"],
"additionalProperties": false
}),
1_500,
)
}
pub fn ina_get_voltage_descriptor() -> ToolDescriptor {
ToolDescriptor::with_timeout(
"ina_get_voltage",
"Read the shunt voltage in Volts from an INA219 sensor on the Jumperless V5. \
sensor: 0 = DAC0/Probe rail, 1 = TOP_RAIL. \
NOTE: This reads the shunt (sense-resistor) voltage, not the bus voltage. \
For bus voltage use ina_get_bus_voltage (separate tool). \
Returns {\"voltage\": float, \"sensor\": int}.",
json!({
"type": "object",
"properties": {
"sensor": {
"type": "integer",
"minimum": 0,
"maximum": 1,
"description": "INA sensor index: 0 = DAC0/Probe, 1 = TOP_RAIL."
}
},
"required": ["sensor"],
"additionalProperties": false
}),
1_500,
)
}
pub fn ina_get_power_descriptor() -> ToolDescriptor {
ToolDescriptor::with_timeout(
"ina_get_power",
"Read the power in Watts from an INA219 sensor on the Jumperless V5. \
sensor: 0 = DAC0/Probe rail, 1 = TOP_RAIL. \
Returns {\"power_watts\": float, \"sensor\": int}.",
json!({
"type": "object",
"properties": {
"sensor": {
"type": "integer",
"minimum": 0,
"maximum": 1,
"description": "INA sensor index: 0 = DAC0/Probe, 1 = TOP_RAIL."
}
},
"required": ["sensor"],
"additionalProperties": false
}),
1_500,
)
}
pub fn descriptors() -> Vec<ToolDescriptor> {
vec![
ina_get_current_descriptor(),
ina_get_voltage_descriptor(),
ina_get_power_descriptor(),
]
}
pub fn handle_ina_get_current<P: Read + Write + ?Sized>(
port: &mut P,
args: &Value,
) -> Result<Value, McpError> {
let sensor_val = args
.get("sensor")
.ok_or_else(|| McpError::Protocol("missing required arg: sensor".into()))?;
let sensor = validate_sensor(sensor_val)?;
let code = format!("print(ina_get_current({sensor}))");
let resp = exec_with_cleanup(port, &code, "ina_get_current")?;
let current: f64 = resp.stdout.trim().parse().map_err(|_| {
McpError::Protocol(format!(
"ina_get_current: unexpected device response: '{}'",
resp.stdout.trim()
))
})?;
Ok(json!({ "current_amps": current, "sensor": sensor }))
}
pub fn handle_ina_get_voltage<P: Read + Write + ?Sized>(
port: &mut P,
args: &Value,
) -> Result<Value, McpError> {
let sensor_val = args
.get("sensor")
.ok_or_else(|| McpError::Protocol("missing required arg: sensor".into()))?;
let sensor = validate_sensor(sensor_val)?;
let code = format!("print(ina_get_voltage({sensor}))");
let resp = exec_with_cleanup(port, &code, "ina_get_voltage")?;
let voltage: f64 = resp.stdout.trim().parse().map_err(|_| {
McpError::Protocol(format!(
"ina_get_voltage: unexpected device response: '{}'",
resp.stdout.trim()
))
})?;
Ok(json!({ "voltage": voltage, "sensor": sensor }))
}
pub fn handle_ina_get_power<P: Read + Write + ?Sized>(
port: &mut P,
args: &Value,
) -> Result<Value, McpError> {
let sensor_val = args
.get("sensor")
.ok_or_else(|| McpError::Protocol("missing required arg: sensor".into()))?;
let sensor = validate_sensor(sensor_val)?;
let code = format!("print(ina_get_power({sensor}))");
let resp = exec_with_cleanup(port, &code, "ina_get_power")?;
let power: f64 = resp.stdout.trim().parse().map_err(|_| {
McpError::Protocol(format!(
"ina_get_power: unexpected device response: '{}'",
resp.stdout.trim()
))
})?;
Ok(json!({ "power_watts": power, "sensor": sensor }))
}
#[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
}
}
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(&"ina_get_current"),
"missing ina_get_current"
);
assert!(
names.contains(&"ina_get_voltage"),
"missing ina_get_voltage"
);
assert!(names.contains(&"ina_get_power"), "missing ina_get_power");
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 ina_get_current_sensor0_happy() {
let frame = MockPort::ok_with_stdout("0.045");
let mut port = MockPort::with_responses(&[&frame]);
let args = json!({"sensor": 0});
let result = handle_ina_get_current(&mut port, &args).unwrap();
assert!((result["current_amps"].as_f64().unwrap() - 0.045).abs() < 1e-9);
assert_eq!(result["sensor"], 0);
let sent = String::from_utf8_lossy(&port.write_data);
assert!(
sent.contains("ina_get_current(0)"),
"expected ina_get_current(0) in command; got: {sent}"
);
}
#[test]
fn ina_get_current_sensor1_happy() {
let frame = MockPort::ok_with_stdout("0.123");
let mut port = MockPort::with_responses(&[&frame]);
let args = json!({"sensor": 1});
let result = handle_ina_get_current(&mut port, &args).unwrap();
assert!((result["current_amps"].as_f64().unwrap() - 0.123).abs() < 1e-9);
assert_eq!(result["sensor"], 1);
let sent = String::from_utf8_lossy(&port.write_data);
assert!(
sent.contains("ina_get_current(1)"),
"expected ina_get_current(1) in command; got: {sent}"
);
}
#[test]
fn ina_get_voltage_sensor0_happy() {
let frame = MockPort::ok_with_stdout("0.00125");
let mut port = MockPort::with_responses(&[&frame]);
let args = json!({"sensor": 0});
let result = handle_ina_get_voltage(&mut port, &args).unwrap();
assert!((result["voltage"].as_f64().unwrap() - 0.00125).abs() < 1e-12);
assert_eq!(result["sensor"], 0);
let sent = String::from_utf8_lossy(&port.write_data);
assert!(
sent.contains("ina_get_voltage(0)"),
"expected ina_get_voltage(0) in command; got: {sent}"
);
}
#[test]
fn ina_get_voltage_sensor1_happy() {
let frame = MockPort::ok_with_stdout("3.3");
let mut port = MockPort::with_responses(&[&frame]);
let args = json!({"sensor": 1});
let result = handle_ina_get_voltage(&mut port, &args).unwrap();
assert!((result["voltage"].as_f64().unwrap() - 3.3).abs() < 1e-9);
assert_eq!(result["sensor"], 1);
}
#[test]
fn ina_get_power_sensor0_happy() {
let frame = MockPort::ok_with_stdout("0.1485");
let mut port = MockPort::with_responses(&[&frame]);
let args = json!({"sensor": 0});
let result = handle_ina_get_power(&mut port, &args).unwrap();
assert!((result["power_watts"].as_f64().unwrap() - 0.1485).abs() < 1e-9);
assert_eq!(result["sensor"], 0);
let sent = String::from_utf8_lossy(&port.write_data);
assert!(
sent.contains("ina_get_power(0)"),
"expected ina_get_power(0) in command; got: {sent}"
);
}
#[test]
fn ina_get_power_sensor1_happy() {
let frame = MockPort::ok_with_stdout("0.4059");
let mut port = MockPort::with_responses(&[&frame]);
let args = json!({"sensor": 1});
let result = handle_ina_get_power(&mut port, &args).unwrap();
assert!((result["power_watts"].as_f64().unwrap() - 0.4059).abs() < 1e-9);
assert_eq!(result["sensor"], 1);
}
#[test]
fn ina_get_current_sensor2_rejected_before_device() {
let mut port = MockPort::with_responses(&[]);
let args = json!({"sensor": 2});
let result = handle_ina_get_current(&mut port, &args);
assert!(result.is_err(), "sensor=2 must be rejected");
assert!(
port.write_data.is_empty(),
"no bytes must be sent for out-of-range sensor"
);
match result.unwrap_err() {
McpError::Protocol(msg) => {
assert!(
msg.contains("0") && msg.contains("1"),
"error must mention valid range; got: {msg}"
);
}
other => panic!("expected McpError::Protocol, got: {other:?}"),
}
}
#[test]
fn ina_get_voltage_sensor2_rejected_before_device() {
let mut port = MockPort::with_responses(&[]);
let args = json!({"sensor": 2});
let result = handle_ina_get_voltage(&mut port, &args);
assert!(result.is_err(), "sensor=2 must be rejected");
assert!(
port.write_data.is_empty(),
"no bytes must be sent for out-of-range sensor"
);
match result.unwrap_err() {
McpError::Protocol(msg) => {
assert!(
msg.contains("0") && msg.contains("1"),
"error must mention valid range; got: {msg}"
);
}
other => panic!("expected McpError::Protocol, got: {other:?}"),
}
}
#[test]
fn ina_get_power_sensor2_rejected_before_device() {
let mut port = MockPort::with_responses(&[]);
let args = json!({"sensor": 2});
let result = handle_ina_get_power(&mut port, &args);
assert!(result.is_err(), "sensor=2 must be rejected");
assert!(
port.write_data.is_empty(),
"no bytes must be sent for out-of-range sensor"
);
match result.unwrap_err() {
McpError::Protocol(msg) => {
assert!(
msg.contains("0") && msg.contains("1"),
"error must mention valid range; got: {msg}"
);
}
other => panic!("expected McpError::Protocol, got: {other:?}"),
}
}
#[test]
fn ina_get_current_bad_device_response_returns_error() {
let frame = MockPort::ok_with_stdout("ERROR: sensor timeout");
let mut port = MockPort::with_responses(&[&frame]);
let args = json!({"sensor": 0});
let result = handle_ina_get_current(&mut port, &args);
assert!(result.is_err());
match result.unwrap_err() {
McpError::Protocol(msg) => {
assert!(
msg.contains("unexpected"),
"error must describe unexpected response; got: {msg}"
);
}
other => panic!("expected McpError::Protocol, got: {other:?}"),
}
}
#[test]
fn ina_get_voltage_bad_device_response_returns_error() {
let frame = MockPort::ok_with_stdout("not_a_float");
let mut port = MockPort::with_responses(&[&frame]);
let args = json!({"sensor": 1});
let result = handle_ina_get_voltage(&mut port, &args);
assert!(result.is_err());
match result.unwrap_err() {
McpError::Protocol(msg) => {
assert!(
msg.contains("unexpected"),
"error must describe unexpected response; got: {msg}"
);
}
other => panic!("expected McpError::Protocol, got: {other:?}"),
}
}
#[test]
fn ina_get_power_bad_device_response_returns_error() {
let frame2 = MockPort::ok_with_stdout("oops");
let mut port2 = MockPort::with_responses(&[&frame2]);
let result = handle_ina_get_power(&mut port2, &json!({"sensor": 1}));
assert!(result.is_err());
match result.unwrap_err() {
McpError::Protocol(msg) => {
assert!(
msg.contains("unexpected"),
"error must describe unexpected response; got: {msg}"
);
}
other => panic!("expected McpError::Protocol, got: {other:?}"),
}
}
#[test]
fn ina_get_current_negative_sensor_rejected() {
let mut port = MockPort::with_responses(&[]);
let args = json!({"sensor": -1});
let result = handle_ina_get_current(&mut port, &args);
assert!(result.is_err(), "negative sensor must be rejected");
assert!(
port.write_data.is_empty(),
"no bytes sent for invalid sensor"
);
}
#[test]
fn ina_get_power_float_sensor_rejected() {
let mut port = MockPort::with_responses(&[]);
let args = json!({"sensor": 0.5});
let result = handle_ina_get_power(&mut port, &args);
assert!(result.is_err(), "float sensor must be rejected");
assert!(port.write_data.is_empty(), "no bytes sent for float sensor");
match result.unwrap_err() {
McpError::Protocol(msg) => {
assert!(
msg.contains("integer"),
"error must require integer; got: {msg}"
);
}
other => panic!("expected McpError::Protocol, got: {other:?}"),
}
}
}