use super::board_profile::BoardProfile;
use super::serial::SerialTransport;
use crate::error::{Result, ZeptoError};
use crate::tools::{Tool, ToolCategory, ToolContext, ToolOutput};
use async_trait::async_trait;
use serde_json::{json, Value};
use std::sync::Arc;
pub(crate) fn validate_hex(hex: &str) -> std::result::Result<(), String> {
if hex.is_empty() {
return Err("Hex data must not be empty".into());
}
if !hex.len().is_multiple_of(2) {
return Err(format!(
"Hex data must have even length (got {} chars)",
hex.len()
));
}
if !hex.chars().all(|c| c.is_ascii_hexdigit()) {
return Err("Hex data must contain only hex digits (0-9, a-f, A-F)".into());
}
Ok(())
}
pub struct I2cScanTool {
pub(crate) transport: Arc<SerialTransport>,
pub(crate) profile: &'static BoardProfile,
}
#[async_trait]
impl Tool for I2cScanTool {
fn name(&self) -> &str {
"i2c_scan"
}
fn description(&self) -> &str {
"Scan an I2C bus for connected devices. Returns a list of detected I2C addresses."
}
fn compact_description(&self) -> &str {
"Scan I2C bus for devices"
}
fn category(&self) -> ToolCategory {
ToolCategory::Hardware
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"bus": {
"type": "integer",
"description": "I2C bus number (default 0)",
"default": 0
}
},
"required": []
})
}
async fn execute(&self, args: Value, _ctx: &ToolContext) -> Result<ToolOutput> {
let bus = args.get("bus").and_then(|v| v.as_u64()).unwrap_or(0) as u8;
self.profile.i2c_bus(bus).ok_or_else(|| {
ZeptoError::Tool(format!(
"I2C bus {bus} not found on board '{}'",
self.profile.name
))
})?;
let result = self
.transport
.request("i2c_scan", json!({ "bus": bus }))
.await?;
Ok(ToolOutput::llm_only(result))
}
}
pub struct I2cReadTool {
pub(crate) transport: Arc<SerialTransport>,
pub(crate) profile: &'static BoardProfile,
}
#[async_trait]
impl Tool for I2cReadTool {
fn name(&self) -> &str {
"i2c_read"
}
fn description(&self) -> &str {
"Read bytes from an I2C device register. \
Specify the bus, 7-bit device address, register address, and number of bytes to read."
}
fn compact_description(&self) -> &str {
"Read I2C device register"
}
fn category(&self) -> ToolCategory {
ToolCategory::Hardware
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"bus": {
"type": "integer",
"description": "I2C bus number (default 0)",
"default": 0
},
"addr": {
"type": "integer",
"description": "7-bit I2C device address (0-127)"
},
"reg": {
"type": "integer",
"description": "Register address to read from"
},
"len": {
"type": "integer",
"description": "Number of bytes to read (default 1)",
"default": 1
}
},
"required": ["addr", "reg"]
})
}
async fn execute(&self, args: Value, _ctx: &ToolContext) -> Result<ToolOutput> {
let bus = args.get("bus").and_then(|v| v.as_u64()).unwrap_or(0) as u8;
let addr = args
.get("addr")
.and_then(|v| v.as_u64())
.ok_or_else(|| ZeptoError::Tool("Missing required parameter 'addr'".into()))?;
let reg = args
.get("reg")
.and_then(|v| v.as_u64())
.ok_or_else(|| ZeptoError::Tool("Missing required parameter 'reg'".into()))?;
let len = args.get("len").and_then(|v| v.as_u64()).unwrap_or(1);
self.profile.i2c_bus(bus).ok_or_else(|| {
ZeptoError::Tool(format!(
"I2C bus {bus} not found on board '{}'",
self.profile.name
))
})?;
if addr > 127 {
return Err(ZeptoError::Tool(format!(
"I2C address {addr} is out of range; must be 0-127 (7-bit)"
)));
}
let result = self
.transport
.request(
"i2c_read",
json!({ "bus": bus, "addr": addr, "reg": reg, "len": len }),
)
.await?;
Ok(ToolOutput::llm_only(result))
}
}
pub struct I2cWriteTool {
pub(crate) transport: Arc<SerialTransport>,
pub(crate) profile: &'static BoardProfile,
}
#[async_trait]
impl Tool for I2cWriteTool {
fn name(&self) -> &str {
"i2c_write"
}
fn description(&self) -> &str {
"Write bytes to an I2C device register. \
Specify the bus, 7-bit device address, register address, and data as a hex string \
(e.g. \"FF00AB\")."
}
fn compact_description(&self) -> &str {
"Write I2C device register"
}
fn category(&self) -> ToolCategory {
ToolCategory::Hardware
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"bus": {
"type": "integer",
"description": "I2C bus number (default 0)",
"default": 0
},
"addr": {
"type": "integer",
"description": "7-bit I2C device address (0-127)"
},
"reg": {
"type": "integer",
"description": "Register address to write to"
},
"data": {
"type": "string",
"description": "Data bytes as an even-length hex string (e.g. \"FF00AB\")"
}
},
"required": ["addr", "reg", "data"]
})
}
async fn execute(&self, args: Value, _ctx: &ToolContext) -> Result<ToolOutput> {
let bus = args.get("bus").and_then(|v| v.as_u64()).unwrap_or(0) as u8;
let addr = args
.get("addr")
.and_then(|v| v.as_u64())
.ok_or_else(|| ZeptoError::Tool("Missing required parameter 'addr'".into()))?;
let reg = args
.get("reg")
.and_then(|v| v.as_u64())
.ok_or_else(|| ZeptoError::Tool("Missing required parameter 'reg'".into()))?;
let data = args
.get("data")
.and_then(|v| v.as_str())
.ok_or_else(|| ZeptoError::Tool("Missing required parameter 'data'".into()))?;
self.profile.i2c_bus(bus).ok_or_else(|| {
ZeptoError::Tool(format!(
"I2C bus {bus} not found on board '{}'",
self.profile.name
))
})?;
if addr > 127 {
return Err(ZeptoError::Tool(format!(
"I2C address {addr} is out of range; must be 0-127 (7-bit)"
)));
}
validate_hex(data).map_err(ZeptoError::Tool)?;
let result = self
.transport
.request(
"i2c_write",
json!({ "bus": bus, "addr": addr, "reg": reg, "data": data }),
)
.await?;
Ok(ToolOutput::llm_only(result))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::peripherals::board_profile::ESP32_PROFILE;
#[test]
fn test_validate_hex_valid() {
assert!(validate_hex("FF").is_ok());
assert!(validate_hex("ff00ab").is_ok());
assert!(validate_hex("00").is_ok());
assert!(validate_hex("DEADBEEF").is_ok());
assert!(validate_hex("0102030405060708").is_ok());
}
#[test]
fn test_validate_hex_empty() {
let err = validate_hex("").unwrap_err();
assert!(err.contains("empty"));
}
#[test]
fn test_validate_hex_odd_length() {
let err = validate_hex("F").unwrap_err();
assert!(err.contains("even length"));
let err2 = validate_hex("ABC").unwrap_err();
assert!(err2.contains("even length"));
}
#[test]
fn test_validate_hex_non_hex_chars() {
let err = validate_hex("GG").unwrap_err();
assert!(err.contains("hex digits"));
let err2 = validate_hex("ZZ").unwrap_err();
assert!(err2.contains("hex digits"));
let err3 = validate_hex("FF GG").unwrap_err();
assert!(err3.contains("even length") || err3.contains("hex digits"));
let err4 = validate_hex("FFGG").unwrap_err();
assert!(err4.contains("hex digits"));
}
#[test]
fn test_esp32_has_bus_0_not_bus_1() {
assert!(ESP32_PROFILE.i2c_bus(0).is_some());
assert!(ESP32_PROFILE.i2c_bus(1).is_none());
}
#[test]
fn test_i2c_address_valid_range() {
for addr in [0u64, 1, 63, 64, 126, 127] {
assert!(addr <= 127, "address {addr} should be valid");
}
}
#[test]
fn test_i2c_address_invalid_range() {
for addr in [128u64, 200, 255, 1000] {
assert!(addr > 127, "address {addr} should be out of range");
}
}
fn scan_parameters() -> Value {
json!({
"type": "object",
"properties": {
"bus": {
"type": "integer",
"description": "I2C bus number (default 0)",
"default": 0
}
},
"required": []
})
}
fn read_parameters() -> Value {
json!({
"type": "object",
"properties": {
"bus": {
"type": "integer",
"description": "I2C bus number (default 0)",
"default": 0
},
"addr": {
"type": "integer",
"description": "7-bit I2C device address (0-127)"
},
"reg": {
"type": "integer",
"description": "Register address to read from"
},
"len": {
"type": "integer",
"description": "Number of bytes to read (default 1)",
"default": 1
}
},
"required": ["addr", "reg"]
})
}
fn write_parameters() -> Value {
json!({
"type": "object",
"properties": {
"bus": {
"type": "integer",
"description": "I2C bus number (default 0)",
"default": 0
},
"addr": {
"type": "integer",
"description": "7-bit I2C device address (0-127)"
},
"reg": {
"type": "integer",
"description": "Register address to write to"
},
"data": {
"type": "string",
"description": "Data bytes as an even-length hex string (e.g. \"FF00AB\")"
}
},
"required": ["addr", "reg", "data"]
})
}
#[test]
fn test_i2c_scan_parameter_schema() {
let schema = scan_parameters();
assert_eq!(schema["type"], "object");
assert!(schema["properties"]["bus"].is_object());
let required = schema["required"].as_array().unwrap();
assert!(required.is_empty(), "bus is optional for scan");
}
#[test]
fn test_i2c_read_parameter_schema() {
let schema = read_parameters();
assert_eq!(schema["type"], "object");
assert!(schema["properties"]["bus"].is_object());
assert!(schema["properties"]["addr"].is_object());
assert!(schema["properties"]["reg"].is_object());
assert!(schema["properties"]["len"].is_object());
let required = schema["required"].as_array().unwrap();
assert!(required.contains(&json!("addr")));
assert!(required.contains(&json!("reg")));
assert!(!required.contains(&json!("bus")), "bus is optional");
assert!(!required.contains(&json!("len")), "len is optional");
}
#[test]
fn test_i2c_write_parameter_schema() {
let schema = write_parameters();
assert_eq!(schema["type"], "object");
assert!(schema["properties"]["bus"].is_object());
assert!(schema["properties"]["addr"].is_object());
assert!(schema["properties"]["reg"].is_object());
assert!(schema["properties"]["data"].is_object());
let required = schema["required"].as_array().unwrap();
assert!(required.contains(&json!("addr")));
assert!(required.contains(&json!("reg")));
assert!(required.contains(&json!("data")));
assert!(!required.contains(&json!("bus")), "bus is optional");
}
#[test]
fn test_validate_hex_mixed_case() {
assert!(validate_hex("DeAdBeEf").is_ok());
}
#[test]
fn test_validate_hex_single_pair() {
assert!(validate_hex("00").is_ok());
assert!(validate_hex("FF").is_ok());
assert!(validate_hex("7f").is_ok());
}
#[test]
fn test_tool_names_and_descriptions_are_non_empty() {
let scan_schema = scan_parameters();
let read_schema = read_parameters();
let write_schema = write_parameters();
assert_eq!(scan_schema["type"].as_str().unwrap(), "object");
assert_eq!(read_schema["type"].as_str().unwrap(), "object");
assert_eq!(write_schema["type"].as_str().unwrap(), "object");
}
}