//! Board state snapshot ToolDefs: `get_state` / `set_state`
//!
//! These are the highest-priority LLM tools per the spec (§"LLM Preferences",
//! §"Structured State Snapshot"). A single `get_state` call gives an LLM a
//! complete, structured snapshot it can reason about programmatically.
//!
//! ## Device API (MicroPython, V5 firmware 5.6.6.2)
//!
//! ```python
//! # get_state() is BROKEN on V5 5.6.6.2 — throws empty UnicodeError.
//! # We synthesize an equivalent snapshot from individual primitives instead.
//! set_state(json, clear_first=True) # → applies state from JSON string
//! ```
//!
//! ## Synthesized get_state
//!
//! `get_state()` throws `UnicodeError()` on V5 firmware 5.6.6.2 (confirmed by
//! live probe). Rather than surfacing that error, `handle_state_get` sends a
//! single Python expression that collects the same information from working
//! primitives and emits one JSON line. The Rust side parses that line and returns
//! the structured `Value`. The snapshot includes `"source": "synthesized"` so
//! callers can detect which path was taken; when firmware ships a working
//! `get_state()` the handler can switch to `"source": "firmware"` with no
//! schema change.
//!
//! Primitives used (all confirmed available on 5.6.6.2):
//! - `get_num_nets()` / `get_net_info(i)` — net list with name, color, nodes
//! - `get_num_bridges()` — bridge count (individual bridge data via `get_bridge(i)`)
//! - `dac_get(0..3)` — DAC0, DAC1, TOP_RAIL, BOTTOM_RAIL voltages
//! - `adc_get(0..3)` — ADC channel voltages
//! - `gpio_get(1..8)` — GPIO pin states ("HIGH"/"LOW"/"FLOATING")
//! - `CURRENT_SLOT` — callable firmware getter; call as `CURRENT_SLOT()` (despite the
//! ALL_CAPS name it is a function, not a constant — live-verified V5 5.6.6.2)
//!
//! `set_state(json_str, clear_first)` accepts a JSON string argument. We must
//! safely embed arbitrary JSON into a MicroPython string literal. The chosen
//! approach: escape `\` → `\\` and `'` → `\'` then embed in a single-quoted
//! Python string literal. This is safe for all valid JSON because JSON strings
//! use `"` as their delimiter (not `'`), so single-quotes only appear as values
//! inside JSON strings — a single-quote in a JSON value is represented as
//! `'` (not `\'`), so escaping them is both correct and necessary.
use crate::base::{McpError, ToolDescriptor};
use serde_json::{json, Value};
use std::io::{Read, Write};
use crate::library::exec_with_cleanup;
/// Empty input schema for zero-arg tools.
///
/// Kept for parity with other tool modules; `state_get_descriptor` inlines its
/// own schema to accommodate the optional `view` arg.
#[allow(dead_code)]
fn no_args() -> Value {
json!({"type": "object", "properties": {}, "additionalProperties": false})
}
// ── ToolDescriptors ───────────────────────────────────────────────────────────
/// Get complete board state as a structured JSON object.
///
/// Tool name: `get_state` (bare spec name per §JSON State API).
///
/// ## `view` arg (consumer separation)
///
/// LLMs default to token-efficient JSON-only output (`view="json"`).
/// Human operators / GUI clients pass `view="pretty"` to also receive a
/// rendered ASCII summary of the board state in a `pretty` field. The
/// structured data is ALWAYS included regardless; `view` only controls
/// whether the rendered text is computed and attached.
pub fn state_get_descriptor() -> ToolDescriptor {
ToolDescriptor::with_timeout(
"get_state",
"Return the complete Jumperless V5 board state as a structured JSON object. Includes \
nets, connections (bridges), power rail voltages (TOP_RAIL, BOTTOM_RAIL, DAC0, DAC1), \
GPIO configuration, slot index, overlay pixel data, and any other state the firmware \
tracks. Single-call alternative to the multi-step pattern of get_num_bridges() + \
get_net_info() per net. Use this to build a complete mental model before making \
changes, or to snapshot state before a destructive operation. \
Optional 'view' arg: 'json' (default, token-efficient) returns structured data only; \
'pretty' additionally attaches a 'pretty' field with an ASCII-rendered monospace \
summary suitable for direct display to a human operator.",
json!({
"type": "object",
"properties": {
"view": {
"type": "string",
"enum": ["json", "pretty"],
"description": "Output mode. 'json' (default) returns structured data only \
(token-efficient for LLM context). 'pretty' additionally \
includes an ASCII-rendered summary in a 'pretty' field."
}
},
"additionalProperties": false
}),
3_000,
)
}
/// Apply a board state from a JSON object.
///
/// Tool name: `set_state` (bare spec name per §JSON State API).
pub fn state_set_descriptor() -> ToolDescriptor {
ToolDescriptor::with_timeout(
"set_state",
"DESTRUCTIVE (when clear_first=true): Removes all current connections before applying \
the new state — save to a slot first if you may need to undo. \
Apply a complete board state from a JSON object (same format as returned by get_state). \
If clear_first is true (default), all existing connections and GPIO state are cleared \
before applying the new state. Set clear_first to false to merge the new state with \
the existing one.",
json!({
"type": "object",
"properties": {
"state": {
"type": "object",
"description": "Board state object (same structure as returned by get_state)."
},
"clear_first": {
"type": "boolean",
"description": "If true (default), clear all existing connections before \
applying state. If false, merge new state with existing."
}
},
"required": ["state"],
"additionalProperties": false
}),
5_000,
)
}
/// Return both state ToolDescriptors.
pub fn descriptors() -> Vec<ToolDescriptor> {
vec![state_get_descriptor(), state_set_descriptor()]
}
// ── Handlers ─────────────────────────────────────────────────────────────────
/// Build the Python synthesizer script sent to the device for `get_state`.
///
/// Returns a single multi-line Python program (no triple-quotes) that collects
/// board state from individual firmware primitives and emits one JSON line.
/// The program is safe to send through `exec_with_cleanup` / paste-mode.
///
/// ## Why synthesize instead of calling `get_state()`?
///
/// `get_state()` throws an empty `UnicodeError()` on V5 firmware 5.6.6.2 (confirmed
/// by live probe). This function builds an equivalent snapshot from working
/// primitives so consumers always get a structured object regardless.
///
/// ## get_net_info serialization notes
///
/// `get_net_info(i)` returns a dict with keys `name`, `number`, `color`,
/// `color_name`, `nodes`. The `color` field is likely an integer (packed RGB)
/// but could be a firmware-internal object on some builds. We pre-coerce
/// non-string fields via `str()` before assembly — MicroPython `json.dumps`
/// does NOT accept a `default=` kwarg, so all values must be JSON-serializable
/// at the point of dump. Per-net errors are returned as a `{_error, number}`
/// sentinel in the nets array rather than silently dropped — so a corrupt
/// firmware net is visible in the snapshot, not invisible.
///
/// The `nodes` field is a comma-separated string per the API reference
/// (e.g. `"D13,TOP_RAIL,GPIO_1"`), not a list — we keep it as-is so callers
/// see the exact firmware representation.
///
/// ## Overlay-fold error surfacing
///
/// `overlay_serialize()` failures (firmware bug, 4096-byte buffer truncation,
/// etc.) attach a `overlays_error` field to the snapshot and emit
/// `overlays: []`. This is distinguishable from a truly-empty overlay slate
/// (which has no `overlays_error` key) — silent fallbacks are not OK here
/// because consumers may make destructive decisions based on overlay state.
fn build_state_synthesizer_code() -> String {
// IMPORTANT: Must not contain ''' or """ anywhere — those break paste-mode framing.
// We use only single-quoted strings and single-line expressions.
//
// Python channel constants for readability in the assembled script:
// DAC channels: 0=DAC0, 1=DAC1, 2=TOP_RAIL, 3=BOTTOM_RAIL
// ADC channels: 0-3
// GPIO pins: 1-8
// Live-verified contracts (V5 firmware 5.6.6.2, 2026-05-12):
// - CURRENT_SLOT is a FUNCTION (returns int slot index); call it.
// - gpio_get returns GPIOState object (not str); str() it to "HIGH"/"LOW"/"FLOATING".
// - get_net_info(i)['color'] may be a non-str type; str() it.
// - MicroPython json.dumps does NOT accept `default=` kwarg. All values must be
// pre-coerced to JSON-serializable primitives before dump.
[
"import json",
"def _ni(i):",
" try:",
" x = get_net_info(i)",
" return {'name': str(x.get('name','')), 'number': x.get('number', i), 'color': str(x.get('color','')), 'color_name': str(x.get('color_name','')), 'nodes': str(x.get('nodes',''))}",
" except Exception as _e:",
" return {'_error': str(_e), 'number': i}",
"snapshot = {",
" 'slot': CURRENT_SLOT() if callable(CURRENT_SLOT) else CURRENT_SLOT,",
" 'nets': [_ni(i) for i in range(get_num_nets())],",
" 'num_bridges': get_num_bridges(),",
" 'rails': {'DAC0': dac_get(0), 'DAC1': dac_get(1), 'TOP_RAIL': dac_get(2), 'BOTTOM_RAIL': dac_get(3)},",
" 'adc': [adc_get(i) for i in range(4)],",
" 'gpio': [{'pin': i, 'value': str(gpio_get(i))} for i in range(1, 9)],",
" 'source': 'synthesized'",
"}",
"try:",
" _ovr_frag = overlay_serialize()",
" _ovr_doc = json.loads('{' + _ovr_frag + '}')",
" snapshot['overlays'] = _ovr_doc.get('overlays', [])",
"except Exception as _e:",
" snapshot['overlays'] = []",
" snapshot['overlays_error'] = str(_e)",
"print(json.dumps(snapshot))",
]
.join("\n")
}
/// Render a human-readable monospace summary of a board state snapshot.
///
/// Pure function — no I/O, no port argument. Takes the parsed `Value` returned
/// by `handle_state_get` (or any snapshot with the same schema) and formats it
/// into a multi-line ASCII string suitable for display or embedding in the
/// `pretty` field of the response.
///
/// ## Overlay pixel encoding
///
/// Each overlay has a `colors` array of hex-string entries in row-major order.
/// A pixel is considered "lit" if its color string is anything other than
/// `"000000"` or `"000"` (case-insensitive). Lit pixels render as `#`; dark
/// pixels render as `.`. The grid is `height` rows × `width` chars.
pub fn render_state_pretty(snapshot: &Value) -> String {
let mut out = String::new();
// ── Header ─────────────────────────────────────────────────────────────
out.push_str("=== JUMPERLESS V5 STATE ===\n");
let slot = snapshot.get("slot").and_then(|v| v.as_i64()).unwrap_or(0);
let bridges = snapshot
.get("num_bridges")
.and_then(|v| v.as_i64())
.unwrap_or(0);
let nets_count = snapshot
.get("nets")
.and_then(|v| v.as_array())
.map(|a| a.len())
.unwrap_or(0);
let source = snapshot
.get("source")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
out.push_str(&format!(
"slot: {slot} bridges: {bridges} nets: {nets_count} source: {source}\n"
));
// ── RAILS ──────────────────────────────────────────────────────────────
out.push_str("\nRAILS\n");
let rail_names = ["DAC0", "DAC1", "TOP_RAIL", "BOTTOM_RAIL"];
if let Some(rails) = snapshot.get("rails").and_then(|v| v.as_object()) {
for name in &rail_names {
let v = rails.get(*name).and_then(|x| x.as_f64()).unwrap_or(0.0);
// Right-align label in a 14-char field (longest is "BOTTOM_RAIL")
out.push_str(&format!(" {:<13} {:>7.2} V\n", format!("{name}:"), v));
}
}
// ── ADC ────────────────────────────────────────────────────────────────
out.push_str("\nADC\n");
if let Some(adc) = snapshot.get("adc").and_then(|v| v.as_array()) {
let entries: Vec<String> = adc
.iter()
.enumerate()
.map(|(i, x)| {
let v = x.as_f64().unwrap_or(0.0);
format!("ADC{i}: {v:.4} V")
})
.collect();
// 4 per line
for chunk in entries.chunks(4) {
out.push_str(" ");
out.push_str(
&chunk
.iter()
.map(|s| format!("{s:<22}"))
.collect::<Vec<_>>()
.join(""),
);
out.push('\n');
}
}
// ── GPIO ───────────────────────────────────────────────────────────────
out.push_str("\nGPIO\n");
if let Some(gpio) = snapshot.get("gpio").and_then(|v| v.as_array()) {
let entries: Vec<String> = gpio
.iter()
.map(|g| {
let pin = g.get("pin").and_then(|p| p.as_i64()).unwrap_or(0);
let val = g.get("value").and_then(|v| v.as_str()).unwrap_or("UNKNOWN");
format!("GPIO_{pin}: {val}")
})
.collect();
for chunk in entries.chunks(4) {
out.push_str(" ");
out.push_str(
&chunk
.iter()
.map(|s| format!("{s:<20}"))
.collect::<Vec<_>>()
.join(""),
);
out.push('\n');
}
}
// ── NETS ───────────────────────────────────────────────────────────────
out.push_str("\nNETS\n");
if let Some(nets) = snapshot.get("nets").and_then(|v| v.as_array()) {
for net in nets {
let number = net.get("number").and_then(|v| v.as_i64()).unwrap_or(-1);
// Per-net error sentinel (from synthesizer): visible in pretty view
// so corrupt nets don't silently render as blank lines. Surfaced by
// SF-hunter R2 review 2026-05-12.
if let Some(err) = net.get("_error").and_then(|v| v.as_str()) {
out.push_str(&format!(" #{number:<3} [ERROR] {err}\n"));
continue;
}
let name = net.get("name").and_then(|v| v.as_str()).unwrap_or("");
let color_name = net.get("color_name").and_then(|v| v.as_str()).unwrap_or("");
let nodes = net.get("nodes").and_then(|v| v.as_str()).unwrap_or("");
// #N name (14 chars) [color_name (8 chars)] nodes
out.push_str(&format!(
" #{number:<3} {:<14} [{:<8}] {nodes}\n",
name, color_name
));
}
}
// ── OVERLAYS ───────────────────────────────────────────────────────────
// Surface overlays_error in the human view if present — otherwise a caller
// using view=pretty has no signal that overlay retrieval failed (and would
// see an empty OVERLAYS section indistinguishable from genuinely empty).
// Surfaced by SF-hunter R2 review 2026-05-12.
if let Some(err) = snapshot.get("overlays_error").and_then(|v| v.as_str()) {
out.push_str(&format!(
"\nOVERLAYS [ERROR — could not retrieve overlay state]\n {err}\n"
));
}
let overlays = snapshot
.get("overlays")
.and_then(|v| v.as_array())
.filter(|a| !a.is_empty());
if let Some(overlays) = overlays {
out.push_str("\nOVERLAYS (# = lit, . = dark)\n");
for overlay in overlays {
let name = overlay.get("name").and_then(|v| v.as_str()).unwrap_or("?");
let row = overlay.get("row").and_then(|v| v.as_i64()).unwrap_or(0);
let col = overlay.get("col").and_then(|v| v.as_i64()).unwrap_or(0);
let width = overlay.get("width").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
let height = overlay.get("height").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
out.push_str(&format!(
" {name} @ row={row} col={col} ({height}x{width})\n"
));
if let Some(colors) = overlay.get("colors").and_then(|v| v.as_array()) {
for row_idx in 0..height {
out.push_str(" ");
for col_idx in 0..width {
let flat_idx = row_idx * width + col_idx;
let color_str = colors
.get(flat_idx)
.and_then(|c| c.as_str())
.unwrap_or("000000");
// Normalise: strip leading '#' if present, compare lower
let normalized = color_str.trim_start_matches('#').to_ascii_lowercase();
let lit =
normalized != "000000" && normalized != "000" && !normalized.is_empty();
out.push(if lit { '#' } else { '.' });
}
out.push('\n');
}
}
}
}
out
}
/// Execute the state synthesizer and return the board snapshot as a parsed JSON `Value`.
///
/// `get_state()` throws an empty `UnicodeError()` on V5 firmware 5.6.6.2. Instead
/// of surfacing that error, this handler builds an equivalent snapshot by calling
/// individual firmware primitives in a single Python script sent via
/// `exec_with_cleanup`. The device emits one JSON line; we parse it on the Rust side
/// and return the structured `Value` directly — NOT a JSON-encoded string.
///
/// The returned object always contains `"source": "synthesized"` to signal this
/// path. When firmware ships a working `get_state()`, switch the code to
/// `print(get_state())` and set `source` to `"firmware"`.
///
/// A JSON parse failure is a hard protocol error — callers must be able to rely
/// on structure.
///
/// ## `view` arg
///
/// Controls whether the `pretty` field is computed and attached. Default is
/// `"json"` (no pretty field — token-efficient for LLM consumers). Pass
/// `view="pretty"` to additionally receive an ASCII-rendered monospace
/// summary suitable for direct display to a human operator. The structured
/// data is ALWAYS included; `view` only gates the additional rendering.
pub fn handle_state_get<P: Read + Write + ?Sized>(
port: &mut P,
args: &Value,
) -> Result<Value, McpError> {
// Default view is "json" (no pretty field). Validate against the enum.
let view = match args.get("view") {
Some(v) => v.as_str().ok_or_else(|| {
McpError::Protocol("get_state: 'view' must be a string ('json' or 'pretty')".into())
})?,
None => "json",
};
if view != "json" && view != "pretty" {
return Err(McpError::Protocol(format!(
"get_state: 'view' must be 'json' or 'pretty'; got '{view}'"
)));
}
let code = build_state_synthesizer_code();
let resp = exec_with_cleanup(port, &code, "get_state")?;
let raw = resp.stdout.trim().to_string();
let mut snapshot = serde_json::from_str::<Value>(&raw).map_err(|e| {
McpError::Protocol(format!(
"get_state: device returned malformed JSON (parse error: {e}); \
raw response: '{raw}'"
))
})?;
if view == "pretty" {
let pretty = render_state_pretty(&snapshot);
snapshot
.as_object_mut()
.map(|m| m.insert("pretty".into(), json!(pretty)));
}
Ok(snapshot)
}
/// Execute `set_state(json_str, clear_first)` and return `{"applied": true}`.
///
/// ## Argument escaping
///
/// The JSON state must be embedded into a MicroPython single-quoted string literal.
/// We escape: `\` → `\\` and `'` → `\'`. This covers all valid JSON:
/// - JSON object/array/number/bool/null contain no `\` or `'` at the syntax level.
/// - JSON string values may contain `\` (as escape sequences) and `'` (as literal
/// characters). Both are safely escaped.
/// - JSON does NOT use `'''` triple-single-quotes anywhere — that construct has no
/// meaning in JSON — so no triple-quote injection risk exists.
pub fn handle_state_set<P: Read + Write + ?Sized>(
port: &mut P,
args: &Value,
) -> Result<Value, McpError> {
// Validate `state` arg: must be present and must be a JSON object.
let state_val = args
.get("state")
.ok_or_else(|| McpError::Protocol("set_state requires a 'state' argument".into()))?;
if !state_val.is_object() {
return Err(McpError::Protocol(
"set_state: 'state' must be a JSON object".into(),
));
}
// Validate `clear_first` if provided: must be a boolean.
let clear_first = match args.get("clear_first") {
Some(v) => v.as_bool().ok_or_else(|| {
McpError::Protocol("set_state: 'clear_first' must be a boolean".into())
})?,
None => true, // Default per firmware API: clear_first=True
};
// Serialize state to a compact JSON string.
let json_str = serde_json::to_string(state_val)
.map_err(|e| McpError::Protocol(format!("set_state: failed to serialize state: {e}")))?;
// Escape for embedding in a Python single-quoted string literal.
// Order matters: escape backslashes FIRST so we don't double-escape the
// new backslashes we add when escaping single-quotes.
let escaped = json_str.replace('\\', "\\\\").replace('\'', "\\'");
let clear_py = if clear_first { "True" } else { "False" };
let code = format!("set_state('{escaped}', clear_first={clear_py})");
exec_with_cleanup(port, &code, "set_state")?;
Ok(json!({ "applied": true, "clear_first": clear_first }))
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use std::collections::VecDeque;
use std::io::{self, Read, Write};
// ── MockPort ──────────────────────────────────────────────────────────────
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(())
}
}
// ── Descriptor tests ──────────────────────────────────────────────────────
#[test]
fn descriptors_have_correct_names() {
let descs = descriptors();
let names: Vec<&str> = descs.iter().map(|d| d.name.as_str()).collect();
// Bare spec names — NOT "state_get" / "state_set"
assert!(
names.contains(&"get_state"),
"must have tool named 'get_state'"
);
assert!(
names.contains(&"set_state"),
"must have tool named 'set_state'"
);
assert_eq!(descs.len(), 2);
}
#[test]
fn 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 get_state_descriptor_has_only_optional_view_arg() {
let d = state_get_descriptor();
// get_state takes only an optional `view` arg — no required fields.
let props = d.input_schema.get("properties").unwrap();
let props_obj = props.as_object().unwrap();
assert!(
props_obj.contains_key("view"),
"get_state must expose a 'view' property"
);
let view = props_obj.get("view").unwrap();
assert_eq!(view.get("type").and_then(|t| t.as_str()), Some("string"));
let enum_vals: Vec<&str> = view
.get("enum")
.and_then(|e| e.as_array())
.map(|a| a.iter().filter_map(|v| v.as_str()).collect())
.unwrap_or_default();
assert!(enum_vals.contains(&"json") && enum_vals.contains(&"pretty"));
// view is optional — no required array.
assert!(
d.input_schema.get("required").is_none(),
"get_state must not have any required args"
);
}
#[test]
fn set_state_descriptor_requires_state_arg() {
let d = state_set_descriptor();
let required = d
.input_schema
.get("required")
.and_then(|v| v.as_array())
.expect("set_state must have a 'required' array");
assert!(
required.iter().any(|v| v.as_str() == Some("state")),
"set_state must require 'state'"
);
// clear_first is optional
assert!(
!required.iter().any(|v| v.as_str() == Some("clear_first")),
"clear_first must be optional (not in required)"
);
}
// ── Handler: get_state ────────────────────────────────────────────────────
#[test]
fn get_state_default_view_is_json_omits_pretty() {
// Default view is "json" (token-efficient for LLM): pretty must NOT be present.
let state_json = r#"{"slot":2,"nets":[],"num_bridges":0,"rails":{"DAC0":0.0,"DAC1":0.0,"TOP_RAIL":5.0,"BOTTOM_RAIL":0.0},"adc":[0.0,0.0,0.0,0.0],"gpio":[{"pin":1,"value":"FLOATING"},{"pin":2,"value":"FLOATING"},{"pin":3,"value":"FLOATING"},{"pin":4,"value":"FLOATING"},{"pin":5,"value":"FLOATING"},{"pin":6,"value":"FLOATING"},{"pin":7,"value":"FLOATING"},{"pin":8,"value":"FLOATING"}],"overlays":[],"source":"synthesized"}"#;
let frame = MockPort::ok_with_stdout(state_json);
let mut port = MockPort::with_responses(&[&frame]);
let result = handle_state_get(&mut port, &json!({})).unwrap();
assert!(result.is_object(), "get_state must return an object Value");
assert_eq!(result["slot"], 2);
assert_eq!(result["rails"]["TOP_RAIL"], 5.0);
assert_eq!(result["source"], "synthesized");
assert!(result["overlays"].is_array());
// pretty must NOT be present in default view="json"
assert!(
result.get("pretty").is_none(),
"default view=json must omit pretty field; got: {result}"
);
}
#[test]
fn get_state_pretty_view_includes_pretty_field() {
// Explicit view="pretty" attaches the rendered text.
let state_json = r#"{"slot":2,"nets":[],"num_bridges":0,"rails":{"DAC0":0.0,"DAC1":0.0,"TOP_RAIL":5.0,"BOTTOM_RAIL":0.0},"adc":[0.0,0.0,0.0,0.0],"gpio":[{"pin":1,"value":"FLOATING"},{"pin":2,"value":"FLOATING"},{"pin":3,"value":"FLOATING"},{"pin":4,"value":"FLOATING"},{"pin":5,"value":"FLOATING"},{"pin":6,"value":"FLOATING"},{"pin":7,"value":"FLOATING"},{"pin":8,"value":"FLOATING"}],"overlays":[],"source":"synthesized"}"#;
let frame = MockPort::ok_with_stdout(state_json);
let mut port = MockPort::with_responses(&[&frame]);
let result = handle_state_get(&mut port, &json!({"view": "pretty"})).unwrap();
assert!(result["pretty"].is_string());
assert!(result["pretty"]
.as_str()
.unwrap()
.contains("JUMPERLESS V5 STATE"));
}
#[test]
fn get_state_invalid_view_returns_error() {
let mut port = MockPort::with_responses(&[]);
let result = handle_state_get(&mut port, &json!({"view": "banana"}));
assert!(result.is_err(), "invalid view value must return Err");
match result.unwrap_err() {
McpError::Protocol(msg) => assert!(
msg.contains("view") && (msg.contains("json") || msg.contains("pretty")),
"error must describe valid view values; got: {msg}"
),
other => panic!("expected McpError::Protocol, got: {other:?}"),
}
// Must not send anything to device.
assert!(
port.write_data.is_empty(),
"invalid view must reject before sending to device"
);
}
#[test]
fn get_state_non_string_view_returns_error() {
let mut port = MockPort::with_responses(&[]);
let result = handle_state_get(&mut port, &json!({"view": 42}));
assert!(result.is_err());
assert!(port.write_data.is_empty());
}
#[test]
fn get_state_synthesizer_code_contains_json_dumps_and_no_triple_quotes() {
let code = build_state_synthesizer_code();
assert!(
code.contains("json.dumps"),
"synthesizer code must contain json.dumps; got:\n{code}"
);
assert!(
code.contains("CURRENT_SLOT"),
"synthesizer code must use CURRENT_SLOT constant; got:\n{code}"
);
assert!(
!code.contains("get_state()"),
"synthesizer code must NOT call get_state() (it throws UnicodeError); got:\n{code}"
);
assert!(
!code.contains("'''"),
"synthesizer code must not contain triple-single-quotes; got:\n{code}"
);
assert!(
!code.contains("\"\"\""),
"synthesizer code must not contain triple-double-quotes; got:\n{code}"
);
assert!(
code.contains("import json"),
"synthesizer code must import json; got:\n{code}"
);
assert!(
code.contains("str(gpio_get"),
"synthesizer code must str()-wrap gpio_get for JSON serialization; got:\n{code}"
);
}
#[test]
fn get_state_malformed_json_returns_protocol_error() {
// Device returning non-JSON (truncated buffer, firmware bug, etc.)
// must produce Err, not a fallback raw response.
let frame = MockPort::ok_with_stdout("not json at all {{{");
let mut port = MockPort::with_responses(&[&frame]);
let result = handle_state_get(&mut port, &json!({}));
assert!(result.is_err(), "malformed JSON must return Err");
match result.unwrap_err() {
McpError::Protocol(msg) => {
assert!(
msg.contains("malformed JSON"),
"error must describe the malformed JSON; got: {msg}"
);
}
other => panic!("expected McpError::Protocol, got: {other:?}"),
}
}
#[test]
fn get_state_device_error_sends_ctrl_c() {
let err = MockPort::error_frame("NameError: name 'get_state' is not defined");
let mut port = MockPort::with_responses(&[&err]);
let result = handle_state_get(&mut port, &json!({}));
assert!(result.is_err());
assert!(
port.write_data.contains(&0x03),
"Ctrl-C must be sent on device error"
);
}
// ── Handler: set_state ────────────────────────────────────────────────────
#[test]
fn set_state_happy_path_default_clear_first() {
// set_state returns nothing useful — just OK frame
let frame = MockPort::ok_frame();
let mut port = MockPort::with_responses(&[&frame]);
let args = json!({
"state": {"slot": 0, "bridges": [[1, 5]]}
});
let result = handle_state_set(&mut port, &args).unwrap();
assert_eq!(result["applied"], true);
// Default clear_first is true
assert_eq!(result["clear_first"], true);
// Verify Python code sent to device includes 'True' for clear_first
let sent = String::from_utf8_lossy(&port.write_data);
assert!(
sent.contains("clear_first=True"),
"default clear_first must be True; sent: {sent}"
);
}
#[test]
fn set_state_explicit_clear_first_false() {
let frame = MockPort::ok_frame();
let mut port = MockPort::with_responses(&[&frame]);
let args = json!({
"state": {"slot": 1},
"clear_first": false
});
let result = handle_state_set(&mut port, &args).unwrap();
assert_eq!(result["applied"], true);
assert_eq!(result["clear_first"], false);
let sent = String::from_utf8_lossy(&port.write_data);
assert!(
sent.contains("clear_first=False"),
"explicit false must send False; sent: {sent}"
);
}
#[test]
fn set_state_missing_state_arg_returns_error() {
let mut port = MockPort::with_responses(&[]);
let args = json!({});
let result = handle_state_set(&mut port, &args);
assert!(result.is_err());
match result.unwrap_err() {
McpError::Protocol(msg) => {
assert!(
msg.contains("state"),
"error must mention 'state' arg; got: {msg}"
);
}
other => panic!("expected McpError::Protocol, got: {other:?}"),
}
}
#[test]
fn set_state_non_object_state_returns_error() {
let mut port = MockPort::with_responses(&[]);
// state must be an object, not a string or array
let args = json!({"state": [1, 2, 3]});
let result = handle_state_set(&mut port, &args);
assert!(result.is_err());
match result.unwrap_err() {
McpError::Protocol(msg) => {
assert!(
msg.contains("object"),
"error must say 'state' must be an object; got: {msg}"
);
}
other => panic!("expected McpError::Protocol, got: {other:?}"),
}
}
#[test]
fn set_state_non_bool_clear_first_returns_error() {
let mut port = MockPort::with_responses(&[]);
let args = json!({"state": {}, "clear_first": "yes"});
let result = handle_state_set(&mut port, &args);
assert!(result.is_err());
match result.unwrap_err() {
McpError::Protocol(msg) => {
assert!(
msg.contains("boolean"),
"error must say clear_first must be boolean; got: {msg}"
);
}
other => panic!("expected McpError::Protocol, got: {other:?}"),
}
}
#[test]
fn set_state_json_with_single_quotes_is_escaped() {
// JSON string values can contain single-quotes; they must be escaped
// before embedding in a Python single-quoted string literal.
let frame = MockPort::ok_frame();
let mut port = MockPort::with_responses(&[&frame]);
let args = json!({
"state": {"name": "it's a test"}
});
let result = handle_state_set(&mut port, &args);
assert!(
result.is_ok(),
"single-quote in state value must not cause error"
);
let sent = String::from_utf8_lossy(&port.write_data);
// The single-quote in "it's" must be escaped as \' in the Python literal
assert!(
sent.contains("\\'"),
"single-quote in JSON value must be escaped as \\'; sent: {sent}"
);
}
#[test]
fn set_state_json_with_backslash_is_escaped() {
// JSON strings with escape sequences (e.g. path strings) must double-escape.
let frame = MockPort::ok_frame();
let mut port = MockPort::with_responses(&[&frame]);
let args = json!({
"state": {"path": "C:\\Users\\test"}
});
let result = handle_state_set(&mut port, &args);
assert!(
result.is_ok(),
"backslash in state value must not cause error"
);
// serde_json will serialize "C:\\Users\\test" as "C:\\\\Users\\\\test" in JSON,
// then we escape those backslashes again for Python.
let sent = String::from_utf8_lossy(&port.write_data);
assert!(
sent.contains("\\\\"),
"backslash in JSON must be double-escaped; sent: {sent}"
);
}
// ── render_state_pretty tests ─────────────────────────────────────────────
/// Build a minimal populated snapshot for renderer tests.
fn make_test_snapshot() -> Value {
json!({
"slot": 1,
"num_bridges": 3,
"source": "synthesized",
"rails": {
"DAC0": 3.37,
"DAC1": 0.0,
"TOP_RAIL": 5.0,
"BOTTOM_RAIL": 0.0
},
"adc": [0.0083, 0.0005, 0.0149, 0.0011],
"gpio": [
{"pin": 1, "value": "FLOATING"},
{"pin": 2, "value": "FLOATING"},
{"pin": 3, "value": "HIGH"},
{"pin": 4, "value": "LOW"},
{"pin": 5, "value": "FLOATING"},
{"pin": 6, "value": "FLOATING"},
{"pin": 7, "value": "FLOATING"},
{"pin": 8, "value": "FLOATING"}
],
"nets": [
{"number": 0, "name": "Empty Net", "color": "0", "color_name": "black", "nodes": "EMPTY"},
{"number": 1, "name": "GND", "color": "1234", "color_name": "seafoam", "nodes": "GND"},
{"number": 2, "name": "Top Rail", "color": "16711680","color_name": "red", "nodes": "TOP_R"}
],
"overlays": []
})
}
#[test]
fn render_state_pretty_includes_section_headers() {
let snap = make_test_snapshot();
let pretty = render_state_pretty(&snap);
for header in &["RAILS", "ADC", "GPIO", "NETS"] {
assert!(
pretty.contains(header),
"pretty output must contain section '{header}'; got:\n{pretty}"
);
}
assert!(
pretty.contains("JUMPERLESS V5 STATE"),
"pretty output must contain title header; got:\n{pretty}"
);
// Spot-check a voltage value appears
assert!(
pretty.contains("3.37"),
"pretty output must include DAC0 voltage 3.37; got:\n{pretty}"
);
// Spot-check a net name appears
assert!(
pretty.contains("GND"),
"pretty output must include net name GND; got:\n{pretty}"
);
}
#[test]
fn render_state_pretty_overlay_lit_pixel_is_hash() {
// Single 1x1 overlay with a lit (non-black) color.
let snap_lit = json!({
"slot": 0, "num_bridges": 0, "source": "synthesized",
"rails": {"DAC0": 0.0, "DAC1": 0.0, "TOP_RAIL": 0.0, "BOTTOM_RAIL": 0.0},
"adc": [0.0, 0.0, 0.0, 0.0],
"gpio": [],
"nets": [],
"overlays": [
{
"name": "test_lit",
"row": 1, "col": 1,
"width": 1, "height": 1,
"colors": ["FC4C00"]
}
]
});
let pretty_lit = render_state_pretty(&snap_lit);
assert!(
pretty_lit.contains('#'),
"lit pixel (FC4C00) must render as '#'; got:\n{pretty_lit}"
);
assert!(
pretty_lit.contains("OVERLAYS"),
"non-empty overlays must produce OVERLAYS section; got:\n{pretty_lit}"
);
// Single 1x1 overlay with a dark (black) color.
let snap_dark = json!({
"slot": 0, "num_bridges": 0, "source": "synthesized",
"rails": {"DAC0": 0.0, "DAC1": 0.0, "TOP_RAIL": 0.0, "BOTTOM_RAIL": 0.0},
"adc": [0.0, 0.0, 0.0, 0.0],
"gpio": [],
"nets": [],
"overlays": [
{
"name": "test_dark",
"row": 1, "col": 1,
"width": 1, "height": 1,
"colors": ["000000"]
}
]
});
let pretty_dark = render_state_pretty(&snap_dark);
// Must have a '.' for the dark pixel and no '#' in the pixel grid
assert!(
pretty_dark.contains('.'),
"dark pixel (000000) must render as '.'; got:\n{pretty_dark}"
);
// The only '#' that could appear is in the header line (===) — not in pixel rows.
// Check the overlay grid row (after the name header) has only dots.
let overlay_section_start = pretty_dark
.find("test_dark")
.expect("overlay name must appear in output");
let after_name = &pretty_dark[overlay_section_start..];
// Find the first pixel row (4 leading spaces + pixel chars).
// The grid row should be " ." (4 spaces + dot).
assert!(
after_name.contains(" ."),
"dark pixel row must render as ' .'; got section:\n{after_name}"
);
}
#[test]
fn render_state_pretty_empty_overlays_omits_section() {
let snap = make_test_snapshot(); // has "overlays": []
let pretty = render_state_pretty(&snap);
assert!(
!pretty.contains("OVERLAYS"),
"empty overlays array must NOT produce an OVERLAYS section; got:\n{pretty}"
);
}
#[test]
fn render_state_pretty_surfaces_overlays_error() {
// R2 fix (SF-hunter): pretty view must visibly indicate overlay retrieval
// failure, not silently render no OVERLAYS section. Otherwise a corrupt
// overlay system looks identical to "no overlays present" to a human.
let mut snap = make_test_snapshot();
snap.as_object_mut()
.unwrap()
.insert("overlays_error".into(), json!("UnicodeError"));
let pretty = render_state_pretty(&snap);
assert!(
pretty.contains("OVERLAYS [ERROR"),
"overlays_error must produce a visible ERROR section; got:\n{pretty}"
);
assert!(
pretty.contains("UnicodeError"),
"error message must appear in output; got:\n{pretty}"
);
}
#[test]
fn render_state_pretty_surfaces_net_error_sentinel() {
// R2 fix (SF-hunter): per-net _error sentinels must be visible in the
// pretty view, not silently rendered as blank lines.
let snap = json!({
"slot": 0,
"num_bridges": 0,
"source": "synthesized",
"rails": {"DAC0": 0.0, "DAC1": 0.0, "TOP_RAIL": 0.0, "BOTTOM_RAIL": 0.0},
"adc": [0.0, 0.0, 0.0, 0.0],
"gpio": [],
"nets": [
{"number": 0, "name": "good", "color": "0", "color_name": "black", "nodes": "GND"},
{"number": 1, "_error": "MemoryError: corrupt entry"}
],
"overlays": []
});
let pretty = render_state_pretty(&snap);
assert!(
pretty.contains("[ERROR]"),
"net _error sentinel must render visibly; got:\n{pretty}"
);
assert!(
pretty.contains("MemoryError"),
"net error message must appear in output; got:\n{pretty}"
);
}
#[test]
fn handle_state_get_response_includes_pretty_field() {
// Full round-trip: mock device returns JSON → handler injects pretty field.
let state_json = r#"{"slot":0,"nets":[{"number":0,"name":"Empty Net","color":"0","color_name":"black","nodes":"EMPTY"}],"num_bridges":1,"rails":{"DAC0":1.8,"DAC1":0.0,"TOP_RAIL":5.0,"BOTTOM_RAIL":0.0},"adc":[0.001,0.002,0.003,0.004],"gpio":[{"pin":1,"value":"HIGH"},{"pin":2,"value":"FLOATING"},{"pin":3,"value":"FLOATING"},{"pin":4,"value":"FLOATING"},{"pin":5,"value":"FLOATING"},{"pin":6,"value":"FLOATING"},{"pin":7,"value":"FLOATING"},{"pin":8,"value":"FLOATING"}],"overlays":[{"name":"border","row":1,"col":1,"width":3,"height":2,"colors":["FC4C00","FC4C00","FC4C00","FC4C00","000000","FC4C00"]}],"source":"synthesized","_note":"test"}"#;
let frame = MockPort::ok_with_stdout(state_json);
let mut port = MockPort::with_responses(&[&frame]);
let result = handle_state_get(&mut port, &json!({"view": "pretty"})).unwrap();
// Data fields must survive unchanged.
assert_eq!(result["slot"], 0);
assert_eq!(result["rails"]["DAC0"], 1.8);
assert_eq!(result["source"], "synthesized");
// pretty field must be present and be a non-empty string.
assert!(
result["pretty"].is_string(),
"response must include a 'pretty' string field"
);
let pretty = result["pretty"].as_str().unwrap();
assert!(!pretty.is_empty(), "pretty field must not be empty");
// pretty must contain all section headers.
for header in &[
"JUMPERLESS V5 STATE",
"RAILS",
"ADC",
"GPIO",
"NETS",
"OVERLAYS",
] {
assert!(
pretty.contains(header),
"pretty must contain '{header}'; got:\n{pretty}"
);
}
// Overlay with a lit pixel must produce '#' in the output.
assert!(
pretty.contains('#'),
"overlay with lit pixels must render '#' chars; got:\n{pretty}"
);
// Spot-check net name and voltage in pretty output.
assert!(pretty.contains("Empty Net"), "pretty must include net name");
assert!(
pretty.contains("1.80"),
"pretty must include DAC0 voltage 1.80"
);
}
}