use crate::base::{McpError, ToolDescriptor};
use serde_json::{json, Value};
use std::io::{Read, Write};
use crate::library::exec_with_cleanup;
const OLED_MAX_LEN: usize = 64;
fn escape_oled_text(text: &str) -> Result<String, McpError> {
if text.len() > OLED_MAX_LEN {
return Err(McpError::Protocol(format!(
"oled_print: text too long ({} chars, max {OLED_MAX_LEN})",
text.len()
)));
}
if text.contains('\n') || text.contains('\r') {
return Err(McpError::Protocol(
"oled_print: text must not contain newline characters (\\n or \\r)".into(),
));
}
if text.contains("'''") || text.contains("\"\"\"") {
return Err(McpError::Protocol(
"oled_print: text must not contain triple-quote sequences (''' or \"\"\")".into(),
));
}
let escaped = text.replace('\\', "\\\\").replace('\'', "\\'");
Ok(escaped)
}
pub fn oled_print_descriptor() -> ToolDescriptor {
ToolDescriptor::with_timeout(
"oled_print",
"Display text on the Jumperless V5 OLED screen (128×32 px). \
'size' controls the font scale: 1=smallest, 4=largest, default=2. \
Text is limited to 64 characters (the maximum displayable at size=2). \
Newlines and triple-quote sequences are not allowed. \
Call oled_clear first if you want to overwrite the previous content. \
Returns {\"printed\": true, \"text\": <as-passed>}.",
json!({
"type": "object",
"properties": {
"text": {
"type": "string",
"maxLength": 64,
"description": "Text to display. No newlines. Max 64 characters."
},
"size": {
"type": "integer",
"minimum": 1,
"maximum": 4,
"description": "Font size scale 1-4 (default 2)"
}
},
"required": ["text"],
"additionalProperties": false
}),
1_500,
)
}
pub fn oled_clear_descriptor() -> ToolDescriptor {
ToolDescriptor::with_timeout(
"oled_clear",
"Clear the Jumperless V5 OLED display. Returns {\"cleared\": true}.",
json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
1_000,
)
}
pub fn descriptors() -> Vec<ToolDescriptor> {
vec![oled_print_descriptor(), oled_clear_descriptor()]
}
pub fn handle_oled_print<P: Read + Write + ?Sized>(
port: &mut P,
args: &Value,
) -> Result<Value, McpError> {
let text = args
.get("text")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError::Protocol("oled_print requires 'text' (string)".into()))?;
let escaped = escape_oled_text(text)?;
let size = match args.get("size") {
Some(v) => {
let n = v.as_i64().ok_or_else(|| {
McpError::Protocol("oled_print: 'size' must be an integer".into())
})?;
if !(1..=4).contains(&n) {
return Err(McpError::Protocol(format!(
"oled_print: size must be 1-4; got {n}"
)));
}
n
}
None => 2,
};
let code = format!("oled_print('{escaped}', {size})");
exec_with_cleanup(port, &code, "oled_print")?;
Ok(json!({
"printed": true,
"text": text
}))
}
pub fn handle_oled_clear<P: Read + Write + ?Sized>(port: &mut P) -> Result<Value, McpError> {
exec_with_cleanup(port, "oled_clear()", "oled_clear")?;
Ok(json!({ "cleared": true }))
}
#[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 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 escape_plain_text_unchanged() {
assert_eq!(escape_oled_text("Hello World").unwrap(), "Hello World");
}
#[test]
fn escape_backslash_doubled() {
assert_eq!(escape_oled_text("a\\b").unwrap(), "a\\\\b");
}
#[test]
fn escape_single_quote_escaped() {
assert_eq!(escape_oled_text("it's").unwrap(), "it\\'s");
}
#[test]
fn escape_backslash_then_quote_both_escaped() {
assert_eq!(escape_oled_text("\\'").unwrap(), "\\\\\\'");
}
#[test]
fn escape_text_too_long_rejected() {
let long = "A".repeat(65);
assert!(escape_oled_text(&long).is_err());
}
#[test]
fn escape_exactly_64_chars_accepted() {
let exactly_64 = "A".repeat(64);
assert!(escape_oled_text(&exactly_64).is_ok());
}
#[test]
fn escape_newline_rejected() {
assert!(escape_oled_text("line1\nline2").is_err());
}
#[test]
fn escape_carriage_return_rejected() {
assert!(escape_oled_text("line\r").is_err());
}
#[test]
fn escape_triple_single_quote_rejected() {
assert!(escape_oled_text("'''").is_err());
}
#[test]
fn escape_triple_double_quote_rejected() {
assert!(escape_oled_text("\"\"\"").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(&"oled_print"));
assert!(names.contains(&"oled_clear"));
assert_eq!(descs.len(), 2);
}
#[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 oled_print_happy_path_default_size() {
let frame = MockPort::ok_frame();
let mut port = MockPort::with_responses(&[&frame]);
let args = json!({"text": "Hello"});
let result = handle_oled_print(&mut port, &args).unwrap();
assert_eq!(result["printed"], true);
assert_eq!(result["text"], "Hello");
let cmd = String::from_utf8_lossy(&port.write_data);
assert!(cmd.contains("oled_print('Hello', 2)"), "cmd was: {cmd}");
}
#[test]
fn oled_print_explicit_size_1() {
let frame = MockPort::ok_frame();
let mut port = MockPort::with_responses(&[&frame]);
let args = json!({"text": "Big", "size": 1});
let result = handle_oled_print(&mut port, &args).unwrap();
assert_eq!(result["printed"], true);
let cmd = String::from_utf8_lossy(&port.write_data);
assert!(cmd.contains("oled_print('Big', 1)"), "cmd was: {cmd}");
}
#[test]
fn oled_print_explicit_size_4() {
let frame = MockPort::ok_frame();
let mut port = MockPort::with_responses(&[&frame]);
let args = json!({"text": "X", "size": 4});
let result = handle_oled_print(&mut port, &args).unwrap();
assert_eq!(result["printed"], true);
let cmd = String::from_utf8_lossy(&port.write_data);
assert!(cmd.contains("oled_print('X', 4)"), "cmd was: {cmd}");
}
#[test]
fn oled_print_size_0_rejected() {
let mut port = MockPort::with_responses(&[]);
let args = json!({"text": "Hi", "size": 0});
assert!(handle_oled_print(&mut port, &args).is_err());
}
#[test]
fn oled_print_size_5_rejected() {
let mut port = MockPort::with_responses(&[]);
let args = json!({"text": "Hi", "size": 5});
assert!(handle_oled_print(&mut port, &args).is_err());
}
#[test]
fn oled_print_text_with_newline_rejected() {
let mut port = MockPort::with_responses(&[]);
let args = json!({"text": "line1\nline2"});
assert!(handle_oled_print(&mut port, &args).is_err());
}
#[test]
fn oled_print_text_too_long_rejected() {
let mut port = MockPort::with_responses(&[]);
let long = "A".repeat(65);
let args = json!({"text": long});
assert!(handle_oled_print(&mut port, &args).is_err());
}
#[test]
fn oled_print_text_with_single_quote_escaped_in_command() {
let frame = MockPort::ok_frame();
let mut port = MockPort::with_responses(&[&frame]);
let args = json!({"text": "it's"});
let result = handle_oled_print(&mut port, &args).unwrap();
assert_eq!(result["text"], "it's");
let cmd = String::from_utf8_lossy(&port.write_data);
assert!(
cmd.contains("oled_print('it\\'s', 2)"),
"cmd must have escaped quote; cmd was: {cmd}"
);
}
#[test]
fn oled_print_text_with_backslash_escaped_in_command() {
let frame = MockPort::ok_frame();
let mut port = MockPort::with_responses(&[&frame]);
let args = json!({"text": "a\\b"});
let result = handle_oled_print(&mut port, &args).unwrap();
assert_eq!(result["text"], "a\\b");
let cmd = String::from_utf8_lossy(&port.write_data);
assert!(
cmd.contains("oled_print('a\\\\b', 2)"),
"cmd must have doubled backslash; cmd was: {cmd}"
);
}
#[test]
fn oled_print_device_error_sends_ctrl_c() {
let err = MockPort::error_frame("NameError: oled_print");
let mut port = MockPort::with_responses(&[&err]);
let args = json!({"text": "Hi"});
let result = handle_oled_print(&mut port, &args);
assert!(result.is_err());
assert!(port.write_data.contains(&0x03));
}
#[test]
fn oled_clear_happy_path() {
let frame = MockPort::ok_frame();
let mut port = MockPort::with_responses(&[&frame]);
let result = handle_oled_clear(&mut port).unwrap();
assert_eq!(result["cleared"], true);
let cmd = String::from_utf8_lossy(&port.write_data);
assert!(cmd.contains("oled_clear()"), "cmd was: {cmd}");
}
#[test]
fn oled_clear_device_error_sends_ctrl_c() {
let err = MockPort::error_frame("NameError: oled_clear");
let mut port = MockPort::with_responses(&[&err]);
let result = handle_oled_clear(&mut port);
assert!(result.is_err());
assert!(port.write_data.contains(&0x03));
}
}