diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index ac8da9f..73e8371 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -81,6 +81,10 @@ func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msg } registry.Register(tools.NewWebFetchTool(50000)) + // Hardware tools (I2C, SPI) - Linux only, returns error on other platforms + registry.Register(tools.NewI2CTool()) + registry.Register(tools.NewSPITool()) + // Message tool - available to both agent and subagent // Subagent uses it to communicate directly with user messageTool := tools.NewMessageTool() diff --git a/pkg/tools/i2c.go b/pkg/tools/i2c.go new file mode 100644 index 0000000..abca5ec --- /dev/null +++ b/pkg/tools/i2c.go @@ -0,0 +1,147 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "path/filepath" + "regexp" + "runtime" +) + +// I2CTool provides I2C bus interaction for reading sensors and controlling peripherals. +type I2CTool struct{} + +func NewI2CTool() *I2CTool { + return &I2CTool{} +} + +func (t *I2CTool) Name() string { + return "i2c" +} + +func (t *I2CTool) Description() string { + return "Interact with I2C bus devices for reading sensors and controlling peripherals. Actions: detect (list buses), scan (find devices on a bus), read (read bytes from device), write (send bytes to device). Linux only." +} + +func (t *I2CTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "action": map[string]interface{}{ + "type": "string", + "enum": []string{"detect", "scan", "read", "write"}, + "description": "Action to perform: detect (list available I2C buses), scan (find devices on a bus), read (read bytes from a device), write (send bytes to a device)", + }, + "bus": map[string]interface{}{ + "type": "string", + "description": "I2C bus number (e.g. \"1\" for /dev/i2c-1). Required for scan/read/write.", + }, + "address": map[string]interface{}{ + "type": "integer", + "description": "7-bit I2C device address (0x03-0x77). Required for read/write.", + }, + "register": map[string]interface{}{ + "type": "integer", + "description": "Register address to read from or write to. If set, sends register byte before read/write.", + }, + "data": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{"type": "integer"}, + "description": "Bytes to write (0-255 each). Required for write action.", + }, + "length": map[string]interface{}{ + "type": "integer", + "description": "Number of bytes to read (1-256). Default: 1. Used with read action.", + }, + "confirm": map[string]interface{}{ + "type": "boolean", + "description": "Must be true for write operations. Safety guard to prevent accidental writes.", + }, + }, + "required": []string{"action"}, + } +} + +func (t *I2CTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { + if runtime.GOOS != "linux" { + return ErrorResult("I2C is only supported on Linux. This tool requires /dev/i2c-* device files.") + } + + action, ok := args["action"].(string) + if !ok { + return ErrorResult("action is required") + } + + switch action { + case "detect": + return t.detect() + case "scan": + return t.scan(args) + case "read": + return t.readDevice(args) + case "write": + return t.writeDevice(args) + default: + return ErrorResult(fmt.Sprintf("unknown action: %s (valid: detect, scan, read, write)", action)) + } +} + +// detect lists available I2C buses by globbing /dev/i2c-* +func (t *I2CTool) detect() *ToolResult { + matches, err := filepath.Glob("/dev/i2c-*") + if err != nil { + return ErrorResult(fmt.Sprintf("failed to scan for I2C buses: %v", err)) + } + + if len(matches) == 0 { + return SilentResult("No I2C buses found. You may need to:\n1. Load the i2c-dev module: modprobe i2c-dev\n2. Check that I2C is enabled in device tree\n3. Configure pinmux for your board (see hardware skill)") + } + + type busInfo struct { + Path string `json:"path"` + Bus string `json:"bus"` + } + + buses := make([]busInfo, 0, len(matches)) + re := regexp.MustCompile(`/dev/i2c-(\d+)`) + for _, m := range matches { + if sub := re.FindStringSubmatch(m); sub != nil { + buses = append(buses, busInfo{Path: m, Bus: sub[1]}) + } + } + + result, _ := json.MarshalIndent(buses, "", " ") + return SilentResult(fmt.Sprintf("Found %d I2C bus(es):\n%s", len(buses), string(result))) +} + +// isValidBusID checks that a bus identifier is a simple number (prevents path injection) +func isValidBusID(id string) bool { + matched, _ := regexp.MatchString(`^\d+$`, id) + return matched +} + +// parseI2CAddress extracts and validates an I2C address from args +func parseI2CAddress(args map[string]interface{}) (int, *ToolResult) { + addrFloat, ok := args["address"].(float64) + if !ok { + return 0, ErrorResult("address is required (e.g. 0x38 for AHT20)") + } + addr := int(addrFloat) + if addr < 0x03 || addr > 0x77 { + return 0, ErrorResult("address must be in valid 7-bit range (0x03-0x77)") + } + return addr, nil +} + +// parseI2CBus extracts and validates an I2C bus from args +func parseI2CBus(args map[string]interface{}) (string, *ToolResult) { + bus, ok := args["bus"].(string) + if !ok || bus == "" { + return "", ErrorResult("bus is required (e.g. \"1\" for /dev/i2c-1)") + } + if !isValidBusID(bus) { + return "", ErrorResult("invalid bus identifier: must be a number (e.g. \"1\")") + } + return bus, nil +} diff --git a/pkg/tools/i2c_linux.go b/pkg/tools/i2c_linux.go new file mode 100644 index 0000000..294f7ec --- /dev/null +++ b/pkg/tools/i2c_linux.go @@ -0,0 +1,282 @@ +package tools + +import ( + "encoding/json" + "fmt" + "syscall" + "unsafe" +) + +// I2C ioctl constants from Linux kernel headers (, ) +const ( + i2cSlave = 0x0703 // Set slave address (fails if in use by driver) + i2cFuncs = 0x0705 // Query adapter functionality bitmask + i2cSmbus = 0x0720 // Perform SMBus transaction + + // I2C_FUNC capability bits + i2cFuncSmbusQuick = 0x00010000 + i2cFuncSmbusReadByte = 0x00020000 + + // SMBus transaction types + i2cSmbusRead = 0 + i2cSmbusWrite = 1 + + // SMBus protocol sizes + i2cSmbusQuick = 0 + i2cSmbusByte = 1 +) + +// i2cSmbusData matches the kernel union i2c_smbus_data (34 bytes max). +// For quick and byte transactions only the first byte is used (if at all). +type i2cSmbusData [34]byte + +// i2cSmbusArgs matches the kernel struct i2c_smbus_ioctl_data. +type i2cSmbusArgs struct { + readWrite uint8 + command uint8 + size uint32 + data *i2cSmbusData +} + +// smbusProbe performs a single SMBus probe at the given address. +// Uses SMBus Quick Write (safest) or falls back to SMBus Read Byte for +// EEPROM address ranges where quick write can corrupt AT24RF08 chips. +// This matches i2cdetect's MODE_AUTO behavior. +func smbusProbe(fd int, addr int, hasQuick bool) bool { + // EEPROM ranges: use read byte (quick write can corrupt AT24RF08) + useReadByte := (addr >= 0x30 && addr <= 0x37) || (addr >= 0x50 && addr <= 0x5F) + + if !useReadByte && hasQuick { + // SMBus Quick Write: [START] [ADDR|W] [ACK/NACK] [STOP] + // Safest probe β€” no data transferred + args := i2cSmbusArgs{ + readWrite: i2cSmbusWrite, + command: 0, + size: i2cSmbusQuick, + data: nil, + } + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cSmbus, uintptr(unsafe.Pointer(&args))) + return errno == 0 + } + + // SMBus Read Byte: [START] [ADDR|R] [ACK/NACK] [DATA] [STOP] + var data i2cSmbusData + args := i2cSmbusArgs{ + readWrite: i2cSmbusRead, + command: 0, + size: i2cSmbusByte, + data: &data, + } + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cSmbus, uintptr(unsafe.Pointer(&args))) + return errno == 0 +} + +// scan probes valid 7-bit addresses on a bus for connected devices. +// Uses the same hybrid probe strategy as i2cdetect's MODE_AUTO: +// SMBus Quick Write for most addresses, SMBus Read Byte for EEPROM ranges. +func (t *I2CTool) scan(args map[string]interface{}) *ToolResult { + bus, errResult := parseI2CBus(args) + if errResult != nil { + return errResult + } + + devPath := fmt.Sprintf("/dev/i2c-%s", bus) + fd, err := syscall.Open(devPath, syscall.O_RDWR, 0) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to open %s: %v (check permissions and i2c-dev module)", devPath, err)) + } + defer syscall.Close(fd) + + // Query adapter capabilities to determine available probe methods. + // I2C_FUNCS writes an unsigned long, which is word-sized on Linux. + var funcs uintptr + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cFuncs, uintptr(unsafe.Pointer(&funcs))) + if errno != 0 { + return ErrorResult(fmt.Sprintf("failed to query I2C adapter capabilities on %s: %v", devPath, errno)) + } + + hasQuick := funcs&i2cFuncSmbusQuick != 0 + hasReadByte := funcs&i2cFuncSmbusReadByte != 0 + + if !hasQuick && !hasReadByte { + return ErrorResult(fmt.Sprintf("I2C adapter %s supports neither SMBus Quick nor Read Byte β€” cannot probe safely", devPath)) + } + + type deviceEntry struct { + Address string `json:"address"` + Status string `json:"status,omitempty"` + } + + var found []deviceEntry + // Scan 0x08-0x77, skipping I2C reserved addresses 0x00-0x07 + for addr := 0x08; addr <= 0x77; addr++ { + // Set slave address β€” EBUSY means a kernel driver owns this address + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cSlave, uintptr(addr)) + if errno != 0 { + if errno == syscall.EBUSY { + found = append(found, deviceEntry{ + Address: fmt.Sprintf("0x%02x", addr), + Status: "busy (in use by kernel driver)", + }) + } + continue + } + + if smbusProbe(fd, addr, hasQuick) { + found = append(found, deviceEntry{ + Address: fmt.Sprintf("0x%02x", addr), + }) + } + } + + if len(found) == 0 { + return SilentResult(fmt.Sprintf("No devices found on %s. Check wiring and pull-up resistors.", devPath)) + } + + result, _ := json.MarshalIndent(map[string]interface{}{ + "bus": devPath, + "devices": found, + "count": len(found), + }, "", " ") + return SilentResult(fmt.Sprintf("Scan of %s:\n%s", devPath, string(result))) +} + +// readDevice reads bytes from an I2C device, optionally at a specific register +func (t *I2CTool) readDevice(args map[string]interface{}) *ToolResult { + bus, errResult := parseI2CBus(args) + if errResult != nil { + return errResult + } + + addr, errResult := parseI2CAddress(args) + if errResult != nil { + return errResult + } + + length := 1 + if l, ok := args["length"].(float64); ok { + length = int(l) + } + if length < 1 || length > 256 { + return ErrorResult("length must be between 1 and 256") + } + + devPath := fmt.Sprintf("/dev/i2c-%s", bus) + fd, err := syscall.Open(devPath, syscall.O_RDWR, 0) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to open %s: %v", devPath, err)) + } + defer syscall.Close(fd) + + // Set slave address + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cSlave, uintptr(addr)) + if errno != 0 { + return ErrorResult(fmt.Sprintf("failed to set I2C address 0x%02x: %v", addr, errno)) + } + + // If register is specified, write it first + if regFloat, ok := args["register"].(float64); ok { + reg := int(regFloat) + if reg < 0 || reg > 255 { + return ErrorResult("register must be between 0x00 and 0xFF") + } + _, err := syscall.Write(fd, []byte{byte(reg)}) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to write register 0x%02x: %v", reg, err)) + } + } + + // Read data + buf := make([]byte, length) + n, err := syscall.Read(fd, buf) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to read from device 0x%02x: %v", addr, err)) + } + + // Format as hex bytes + hexBytes := make([]string, n) + intBytes := make([]int, n) + for i := 0; i < n; i++ { + hexBytes[i] = fmt.Sprintf("0x%02x", buf[i]) + intBytes[i] = int(buf[i]) + } + + result, _ := json.MarshalIndent(map[string]interface{}{ + "bus": devPath, + "address": fmt.Sprintf("0x%02x", addr), + "bytes": intBytes, + "hex": hexBytes, + "length": n, + }, "", " ") + return SilentResult(string(result)) +} + +// writeDevice writes bytes to an I2C device, optionally at a specific register +func (t *I2CTool) writeDevice(args map[string]interface{}) *ToolResult { + confirm, _ := args["confirm"].(bool) + if !confirm { + return ErrorResult("write operations require confirm: true. Please confirm with the user before writing to I2C devices, as incorrect writes can misconfigure hardware.") + } + + bus, errResult := parseI2CBus(args) + if errResult != nil { + return errResult + } + + addr, errResult := parseI2CAddress(args) + if errResult != nil { + return errResult + } + + dataRaw, ok := args["data"].([]interface{}) + if !ok || len(dataRaw) == 0 { + return ErrorResult("data is required for write (array of byte values 0-255)") + } + if len(dataRaw) > 256 { + return ErrorResult("data too long: maximum 256 bytes per I2C transaction") + } + + data := make([]byte, 0, len(dataRaw)+1) + + // If register is specified, prepend it to the data + if regFloat, ok := args["register"].(float64); ok { + reg := int(regFloat) + if reg < 0 || reg > 255 { + return ErrorResult("register must be between 0x00 and 0xFF") + } + data = append(data, byte(reg)) + } + + for i, v := range dataRaw { + f, ok := v.(float64) + if !ok { + return ErrorResult(fmt.Sprintf("data[%d] is not a valid byte value", i)) + } + b := int(f) + if b < 0 || b > 255 { + return ErrorResult(fmt.Sprintf("data[%d] = %d is out of byte range (0-255)", i, b)) + } + data = append(data, byte(b)) + } + + devPath := fmt.Sprintf("/dev/i2c-%s", bus) + fd, err := syscall.Open(devPath, syscall.O_RDWR, 0) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to open %s: %v", devPath, err)) + } + defer syscall.Close(fd) + + // Set slave address + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cSlave, uintptr(addr)) + if errno != 0 { + return ErrorResult(fmt.Sprintf("failed to set I2C address 0x%02x: %v", addr, errno)) + } + + // Write data + n, err := syscall.Write(fd, data) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to write to device 0x%02x: %v", addr, err)) + } + + return SilentResult(fmt.Sprintf("Wrote %d byte(s) to device 0x%02x on %s", n, addr, devPath)) +} diff --git a/pkg/tools/i2c_other.go b/pkg/tools/i2c_other.go new file mode 100644 index 0000000..d1d5813 --- /dev/null +++ b/pkg/tools/i2c_other.go @@ -0,0 +1,18 @@ +//go:build !linux + +package tools + +// scan is a stub for non-Linux platforms. +func (t *I2CTool) scan(args map[string]interface{}) *ToolResult { + return ErrorResult("I2C is only supported on Linux") +} + +// readDevice is a stub for non-Linux platforms. +func (t *I2CTool) readDevice(args map[string]interface{}) *ToolResult { + return ErrorResult("I2C is only supported on Linux") +} + +// writeDevice is a stub for non-Linux platforms. +func (t *I2CTool) writeDevice(args map[string]interface{}) *ToolResult { + return ErrorResult("I2C is only supported on Linux") +} diff --git a/pkg/tools/spi.go b/pkg/tools/spi.go new file mode 100644 index 0000000..4805d6a --- /dev/null +++ b/pkg/tools/spi.go @@ -0,0 +1,156 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "path/filepath" + "regexp" + "runtime" +) + +// SPITool provides SPI bus interaction for high-speed peripheral communication. +type SPITool struct{} + +func NewSPITool() *SPITool { + return &SPITool{} +} + +func (t *SPITool) Name() string { + return "spi" +} + +func (t *SPITool) Description() string { + return "Interact with SPI bus devices for high-speed peripheral communication. Actions: list (find SPI devices), transfer (full-duplex send/receive), read (receive bytes). Linux only." +} + +func (t *SPITool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "action": map[string]interface{}{ + "type": "string", + "enum": []string{"list", "transfer", "read"}, + "description": "Action to perform: list (find available SPI devices), transfer (full-duplex send/receive), read (receive bytes by sending zeros)", + }, + "device": map[string]interface{}{ + "type": "string", + "description": "SPI device identifier (e.g. \"2.0\" for /dev/spidev2.0). Required for transfer/read.", + }, + "speed": map[string]interface{}{ + "type": "integer", + "description": "SPI clock speed in Hz. Default: 1000000 (1 MHz).", + }, + "mode": map[string]interface{}{ + "type": "integer", + "description": "SPI mode (0-3). Default: 0. Mode sets CPOL and CPHA: 0=0,0 1=0,1 2=1,0 3=1,1.", + }, + "bits": map[string]interface{}{ + "type": "integer", + "description": "Bits per word. Default: 8.", + }, + "data": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{"type": "integer"}, + "description": "Bytes to send (0-255 each). Required for transfer action.", + }, + "length": map[string]interface{}{ + "type": "integer", + "description": "Number of bytes to read (1-4096). Required for read action.", + }, + "confirm": map[string]interface{}{ + "type": "boolean", + "description": "Must be true for transfer operations. Safety guard to prevent accidental writes.", + }, + }, + "required": []string{"action"}, + } +} + +func (t *SPITool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { + if runtime.GOOS != "linux" { + return ErrorResult("SPI is only supported on Linux. This tool requires /dev/spidev* device files.") + } + + action, ok := args["action"].(string) + if !ok { + return ErrorResult("action is required") + } + + switch action { + case "list": + return t.list() + case "transfer": + return t.transfer(args) + case "read": + return t.readDevice(args) + default: + return ErrorResult(fmt.Sprintf("unknown action: %s (valid: list, transfer, read)", action)) + } +} + +// list finds available SPI devices by globbing /dev/spidev* +func (t *SPITool) list() *ToolResult { + matches, err := filepath.Glob("/dev/spidev*") + if err != nil { + return ErrorResult(fmt.Sprintf("failed to scan for SPI devices: %v", err)) + } + + if len(matches) == 0 { + return SilentResult("No SPI devices found. You may need to:\n1. Enable SPI in device tree\n2. Configure pinmux for your board (see hardware skill)\n3. Check that spidev module is loaded") + } + + type devInfo struct { + Path string `json:"path"` + Device string `json:"device"` + } + + devices := make([]devInfo, 0, len(matches)) + re := regexp.MustCompile(`/dev/spidev(\d+\.\d+)`) + for _, m := range matches { + if sub := re.FindStringSubmatch(m); sub != nil { + devices = append(devices, devInfo{Path: m, Device: sub[1]}) + } + } + + result, _ := json.MarshalIndent(devices, "", " ") + return SilentResult(fmt.Sprintf("Found %d SPI device(s):\n%s", len(devices), string(result))) +} + +// parseSPIArgs extracts and validates common SPI parameters +func parseSPIArgs(args map[string]interface{}) (device string, speed uint32, mode uint8, bits uint8, errMsg string) { + dev, ok := args["device"].(string) + if !ok || dev == "" { + return "", 0, 0, 0, "device is required (e.g. \"2.0\" for /dev/spidev2.0)" + } + matched, _ := regexp.MatchString(`^\d+\.\d+$`, dev) + if !matched { + return "", 0, 0, 0, "invalid device identifier: must be in format \"X.Y\" (e.g. \"2.0\")" + } + + speed = 1000000 // default 1 MHz + if s, ok := args["speed"].(float64); ok { + if s < 1 || s > 125000000 { + return "", 0, 0, 0, "speed must be between 1 Hz and 125 MHz" + } + speed = uint32(s) + } + + mode = 0 + if m, ok := args["mode"].(float64); ok { + if int(m) < 0 || int(m) > 3 { + return "", 0, 0, 0, "mode must be 0-3" + } + mode = uint8(m) + } + + bits = 8 + if b, ok := args["bits"].(float64); ok { + if int(b) < 1 || int(b) > 32 { + return "", 0, 0, 0, "bits must be between 1 and 32" + } + bits = uint8(b) + } + + return dev, speed, mode, bits, "" +} diff --git a/pkg/tools/spi_linux.go b/pkg/tools/spi_linux.go new file mode 100644 index 0000000..12b6960 --- /dev/null +++ b/pkg/tools/spi_linux.go @@ -0,0 +1,196 @@ +package tools + +import ( + "encoding/json" + "fmt" + "runtime" + "syscall" + "unsafe" +) + +// SPI ioctl constants from Linux kernel headers. +// Calculated from _IOW('k', nr, size) macro: +// +// direction(1)<<30 | size<<16 | type(0x6B)<<8 | nr +const ( + spiIocWrMode = 0x40016B01 // _IOW('k', 1, __u8) + spiIocWrBitsPerWord = 0x40016B03 // _IOW('k', 3, __u8) + spiIocWrMaxSpeedHz = 0x40046B04 // _IOW('k', 4, __u32) + spiIocMessage1 = 0x40206B00 // _IOW('k', 0, struct spi_ioc_transfer) β€” 32 bytes +) + +// spiTransfer matches Linux kernel struct spi_ioc_transfer (32 bytes on all architectures). +type spiTransfer struct { + txBuf uint64 + rxBuf uint64 + length uint32 + speedHz uint32 + delayUsecs uint16 + bitsPerWord uint8 + csChange uint8 + txNbits uint8 + rxNbits uint8 + wordDelay uint8 + pad uint8 +} + +// configureSPI opens an SPI device and sets mode, bits per word, and speed +func configureSPI(devPath string, mode uint8, bits uint8, speed uint32) (int, *ToolResult) { + fd, err := syscall.Open(devPath, syscall.O_RDWR, 0) + if err != nil { + return -1, ErrorResult(fmt.Sprintf("failed to open %s: %v (check permissions and spidev module)", devPath, err)) + } + + // Set SPI mode + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), spiIocWrMode, uintptr(unsafe.Pointer(&mode))) + if errno != 0 { + syscall.Close(fd) + return -1, ErrorResult(fmt.Sprintf("failed to set SPI mode %d: %v", mode, errno)) + } + + // Set bits per word + _, _, errno = syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), spiIocWrBitsPerWord, uintptr(unsafe.Pointer(&bits))) + if errno != 0 { + syscall.Close(fd) + return -1, ErrorResult(fmt.Sprintf("failed to set bits per word %d: %v", bits, errno)) + } + + // Set max speed + _, _, errno = syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), spiIocWrMaxSpeedHz, uintptr(unsafe.Pointer(&speed))) + if errno != 0 { + syscall.Close(fd) + return -1, ErrorResult(fmt.Sprintf("failed to set SPI speed %d Hz: %v", speed, errno)) + } + + return fd, nil +} + +// transfer performs a full-duplex SPI transfer +func (t *SPITool) transfer(args map[string]interface{}) *ToolResult { + confirm, _ := args["confirm"].(bool) + if !confirm { + return ErrorResult("transfer operations require confirm: true. Please confirm with the user before sending data to SPI devices.") + } + + dev, speed, mode, bits, errMsg := parseSPIArgs(args) + if errMsg != "" { + return ErrorResult(errMsg) + } + + dataRaw, ok := args["data"].([]interface{}) + if !ok || len(dataRaw) == 0 { + return ErrorResult("data is required for transfer (array of byte values 0-255)") + } + if len(dataRaw) > 4096 { + return ErrorResult("data too long: maximum 4096 bytes per SPI transfer") + } + + txBuf := make([]byte, len(dataRaw)) + for i, v := range dataRaw { + f, ok := v.(float64) + if !ok { + return ErrorResult(fmt.Sprintf("data[%d] is not a valid byte value", i)) + } + b := int(f) + if b < 0 || b > 255 { + return ErrorResult(fmt.Sprintf("data[%d] = %d is out of byte range (0-255)", i, b)) + } + txBuf[i] = byte(b) + } + + devPath := fmt.Sprintf("/dev/spidev%s", dev) + fd, errResult := configureSPI(devPath, mode, bits, speed) + if errResult != nil { + return errResult + } + defer syscall.Close(fd) + + rxBuf := make([]byte, len(txBuf)) + + xfer := spiTransfer{ + txBuf: uint64(uintptr(unsafe.Pointer(&txBuf[0]))), + rxBuf: uint64(uintptr(unsafe.Pointer(&rxBuf[0]))), + length: uint32(len(txBuf)), + speedHz: speed, + bitsPerWord: bits, + } + + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), spiIocMessage1, uintptr(unsafe.Pointer(&xfer))) + runtime.KeepAlive(txBuf) + runtime.KeepAlive(rxBuf) + if errno != 0 { + return ErrorResult(fmt.Sprintf("SPI transfer failed: %v", errno)) + } + + // Format received bytes + hexBytes := make([]string, len(rxBuf)) + intBytes := make([]int, len(rxBuf)) + for i, b := range rxBuf { + hexBytes[i] = fmt.Sprintf("0x%02x", b) + intBytes[i] = int(b) + } + + result, _ := json.MarshalIndent(map[string]interface{}{ + "device": devPath, + "sent": len(txBuf), + "received": intBytes, + "hex": hexBytes, + }, "", " ") + return SilentResult(string(result)) +} + +// readDevice reads bytes from SPI by sending zeros (read-only, no confirm needed) +func (t *SPITool) readDevice(args map[string]interface{}) *ToolResult { + dev, speed, mode, bits, errMsg := parseSPIArgs(args) + if errMsg != "" { + return ErrorResult(errMsg) + } + + length := 0 + if l, ok := args["length"].(float64); ok { + length = int(l) + } + if length < 1 || length > 4096 { + return ErrorResult("length is required for read (1-4096)") + } + + devPath := fmt.Sprintf("/dev/spidev%s", dev) + fd, errResult := configureSPI(devPath, mode, bits, speed) + if errResult != nil { + return errResult + } + defer syscall.Close(fd) + + txBuf := make([]byte, length) // zeros + rxBuf := make([]byte, length) + + xfer := spiTransfer{ + txBuf: uint64(uintptr(unsafe.Pointer(&txBuf[0]))), + rxBuf: uint64(uintptr(unsafe.Pointer(&rxBuf[0]))), + length: uint32(length), + speedHz: speed, + bitsPerWord: bits, + } + + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), spiIocMessage1, uintptr(unsafe.Pointer(&xfer))) + runtime.KeepAlive(txBuf) + runtime.KeepAlive(rxBuf) + if errno != 0 { + return ErrorResult(fmt.Sprintf("SPI read failed: %v", errno)) + } + + hexBytes := make([]string, len(rxBuf)) + intBytes := make([]int, len(rxBuf)) + for i, b := range rxBuf { + hexBytes[i] = fmt.Sprintf("0x%02x", b) + intBytes[i] = int(b) + } + + result, _ := json.MarshalIndent(map[string]interface{}{ + "device": devPath, + "bytes": intBytes, + "hex": hexBytes, + "length": len(rxBuf), + }, "", " ") + return SilentResult(string(result)) +} diff --git a/pkg/tools/spi_other.go b/pkg/tools/spi_other.go new file mode 100644 index 0000000..6dfc86f --- /dev/null +++ b/pkg/tools/spi_other.go @@ -0,0 +1,13 @@ +//go:build !linux + +package tools + +// transfer is a stub for non-Linux platforms. +func (t *SPITool) transfer(args map[string]interface{}) *ToolResult { + return ErrorResult("SPI is only supported on Linux") +} + +// readDevice is a stub for non-Linux platforms. +func (t *SPITool) readDevice(args map[string]interface{}) *ToolResult { + return ErrorResult("SPI is only supported on Linux") +} diff --git a/skills/hardware/SKILL.md b/skills/hardware/SKILL.md new file mode 100644 index 0000000..e89d1b6 --- /dev/null +++ b/skills/hardware/SKILL.md @@ -0,0 +1,64 @@ +--- +name: hardware +description: Read and control I2C and SPI peripherals on Sipeed boards (LicheeRV Nano, MaixCAM, NanoKVM). +homepage: https://wiki.sipeed.com/hardware/en/lichee/RV_Nano/1_intro.html +metadata: {"nanobot":{"emoji":"πŸ”§","requires":{"tools":["i2c","spi"]}}} +--- + +# Hardware (I2C / SPI) + +Use the `i2c` and `spi` tools to interact with sensors, displays, and other peripherals connected to the board. + +## Quick Start + +``` +# 1. Find available buses +i2c detect + +# 2. Scan for connected devices +i2c scan (bus: "1") + +# 3. Read from a sensor (e.g. AHT20 temperature/humidity) +i2c read (bus: "1", address: 0x38, register: 0xAC, length: 6) + +# 4. SPI devices +spi list +spi read (device: "2.0", length: 4) +``` + +## Before You Start β€” Pinmux Setup + +Most I2C/SPI pins are shared with WiFi on Sipeed boards. You must configure pinmux before use. + +See `references/board-pinout.md` for board-specific commands. + +**Common steps:** +1. Stop WiFi if using shared pins: `/etc/init.d/S30wifi stop` +2. Load i2c-dev module: `modprobe i2c-dev` +3. Configure pinmux with `devmem` (board-specific) +4. Verify with `i2c detect` and `i2c scan` + +## Safety + +- **Write operations** require `confirm: true` β€” always confirm with the user first +- I2C addresses are validated to 7-bit range (0x03-0x77) +- SPI modes are validated (0-3 only) +- Maximum per-transaction: 256 bytes (I2C), 4096 bytes (SPI) + +## Common Devices + +See `references/common-devices.md` for register maps and usage of popular sensors: +AHT20, BME280, SSD1306 OLED, MPU6050 IMU, DS3231 RTC, INA219 power monitor, PCA9685 PWM, and more. + +## Troubleshooting + +| Problem | Solution | +|---------|----------| +| No I2C buses found | `modprobe i2c-dev` and check device tree | +| Permission denied | Run as root or add user to `i2c` group | +| No devices on scan | Check wiring, pull-up resistors (4.7k typical), and pinmux | +| Bus number changed | I2C adapter numbers can shift between boots; use `i2c detect` to find current assignment | +| WiFi stopped working | I2C-1/SPI-2 share pins with WiFi SDIO; can't use both simultaneously | +| `devmem` not found | Download separately or use `busybox devmem` | +| SPI transfer returns all zeros | Check MISO wiring and device power | +| SPI transfer returns all 0xFF | Device not responding; check CS pin and clock polarity (mode) | diff --git a/skills/hardware/references/board-pinout.md b/skills/hardware/references/board-pinout.md new file mode 100644 index 0000000..827dd06 --- /dev/null +++ b/skills/hardware/references/board-pinout.md @@ -0,0 +1,131 @@ +# Board Pinout & Pinmux Reference + +## LicheeRV Nano (SG2002) + +### I2C Buses + +| Bus | Pins | Notes | +|-----|------|-------| +| I2C-1 | P18 (SCL), P21 (SDA) | **Shared with WiFi SDIO** β€” must stop WiFi first | +| I2C-3 | Available on header | Check device tree for pin assignment | +| I2C-5 | Software (BitBang) | Slower but no pin conflicts | + +### SPI Buses + +| Bus | Pins | Notes | +|-----|------|-------| +| SPI-2 | P18 (CS), P21 (MISO), P22 (MOSI), P23 (SCK) | **Shared with WiFi** β€” must stop WiFi first | +| SPI-4 | Software (BitBang) | Slower but no pin conflicts | + +### Setup Steps for I2C-1 + +```bash +# 1. Stop WiFi (shares pins with I2C-1) +/etc/init.d/S30wifi stop + +# 2. Configure pinmux for I2C-1 +devmem 0x030010D0 b 0x2 # P18 β†’ I2C1_SCL +devmem 0x030010DC b 0x2 # P21 β†’ I2C1_SDA + +# 3. Load i2c-dev module +modprobe i2c-dev + +# 4. Verify +ls /dev/i2c-* +``` + +### Setup Steps for SPI-2 + +```bash +# 1. Stop WiFi (shares pins with SPI-2) +/etc/init.d/S30wifi stop + +# 2. Configure pinmux for SPI-2 +devmem 0x030010D0 b 0x1 # P18 β†’ SPI2_CS +devmem 0x030010DC b 0x1 # P21 β†’ SPI2_MISO +devmem 0x030010E0 b 0x1 # P22 β†’ SPI2_MOSI +devmem 0x030010E4 b 0x1 # P23 β†’ SPI2_SCK + +# 3. Verify +ls /dev/spidev* +``` + +### Max Tested SPI Speed +- SPI-2 hardware: tested up to **93 MHz** +- `spidev_test` is pre-installed on the official image for loopback testing + +--- + +## MaixCAM + +### I2C Buses + +| Bus | Pins | Notes | +|-----|------|-------| +| I2C-1 | Overlaps with WiFi | Not recommended | +| I2C-3 | Overlaps with WiFi | Not recommended | +| I2C-5 | A15 (SCL), A27 (SDA) | **Recommended** β€” software I2C, no conflicts | + +### Setup Steps for I2C-5 + +```bash +# Configure pins using pinmap utility +# (MaixCAM uses a pinmap tool instead of devmem) +# Refer to: https://wiki.sipeed.com/hardware/en/maixcam/gpio.html + +# Load i2c-dev +modprobe i2c-dev + +# Verify +ls /dev/i2c-* +``` + +--- + +## MaixCAM2 + +### I2C Buses + +| Bus | Pins | Notes | +|-----|------|-------| +| I2C-6 | A1 (SCL), A0 (SDA) | Available on header | +| I2C-7 | Available | Check device tree | + +### Setup Steps + +```bash +# Configure pinmap for I2C-6 +# A1 β†’ I2C6_SCL, A0 β†’ I2C6_SDA +# Refer to MaixCAM2 documentation for pinmap commands + +modprobe i2c-dev +ls /dev/i2c-* +``` + +--- + +## NanoKVM + +Uses the same SG2002 SoC as LicheeRV Nano. GPIO and I2C access follows the same pinmux procedure. Refer to the LicheeRV Nano section above. + +Check NanoKVM-specific pin headers for available I2C/SPI lines: +- https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/introduction.html + +--- + +## Common Issues + +### devmem not found +The `devmem` utility may not be in the default image. Options: +- Use `busybox devmem` if busybox is installed +- Download devmem from the Sipeed package repository +- Cross-compile from source (single C file) + +### Dynamic bus numbering +I2C adapter numbers can change between boots depending on driver load order. Always use `i2c detect` to find current bus assignments rather than hardcoding bus numbers. + +### Permissions +`/dev/i2c-*` and `/dev/spidev*` typically require root access. Options: +- Run picoclaw as root +- Add user to `i2c` and `spi` groups +- Create udev rules: `SUBSYSTEM=="i2c-dev", MODE="0666"` diff --git a/skills/hardware/references/common-devices.md b/skills/hardware/references/common-devices.md new file mode 100644 index 0000000..715e8ab --- /dev/null +++ b/skills/hardware/references/common-devices.md @@ -0,0 +1,78 @@ +# Common I2C/SPI Device Reference + +## I2C Devices + +### AHT20 β€” Temperature & Humidity +- **Address:** 0x38 +- **Init:** Write `[0xBE, 0x08, 0x00]` then wait 10ms +- **Measure:** Write `[0xAC, 0x33, 0x00]`, wait 80ms, read 6 bytes +- **Parse:** Status=byte[0], Humidity=(byte[1]<<12|byte[2]<<4|byte[3]>>4)/2^20*100, Temp=(byte[3]&0x0F<<16|byte[4]<<8|byte[5])/2^20*200-50 +- **Notes:** No register addressing β€” write command bytes directly (omit `register` param) + +### BME280 / BMP280 β€” Temperature, Humidity, Pressure +- **Address:** 0x76 or 0x77 (SDO pin selects) +- **Chip ID register:** 0xD0 β†’ BMP280=0x58, BME280=0x60 +- **Data registers:** 0xF7-0xFE (pressure, temperature, humidity) +- **Config:** Write 0xF2 (humidity oversampling), 0xF4 (temp/press oversampling + mode), 0xF5 (standby, filter) +- **Forced measurement:** Write `[0x25]` to register 0xF4, wait 40ms, read 8 bytes from 0xF7 +- **Calibration:** Read 26 bytes from 0x88 and 7 bytes from 0xE1 for compensation formulas +- **Also available via SPI** (mode 0 or 3) + +### SSD1306 β€” 128x64 OLED Display +- **Address:** 0x3C (or 0x3D if SA0 high) +- **Command prefix:** 0x00 (write to register 0x00) +- **Data prefix:** 0x40 (write to register 0x40) +- **Init sequence:** `[0xAE, 0xD5, 0x80, 0xA8, 0x3F, 0xD3, 0x00, 0x40, 0x8D, 0x14, 0x20, 0x00, 0xA1, 0xC8, 0xDA, 0x12, 0x81, 0xCF, 0xD9, 0xF1, 0xDB, 0x40, 0xA4, 0xA6, 0xAF]` +- **Display on:** 0xAF, **Display off:** 0xAE +- **Also available via SPI** (faster, recommended for animations) + +### MPU6050 β€” 6-axis Accelerometer + Gyroscope +- **Address:** 0x68 (or 0x69 if AD0 high) +- **WHO_AM_I:** Register 0x75 β†’ should return 0x68 +- **Wake up:** Write `[0x00]` to register 0x6B (clear sleep bit) +- **Read accel:** 6 bytes from register 0x3B (XH,XL,YH,YL,ZH,ZL) β€” signed 16-bit, default Β±2g +- **Read gyro:** 6 bytes from register 0x43 β€” signed 16-bit, default Β±250Β°/s +- **Read temp:** 2 bytes from register 0x41 β€” TempΒ°C = value/340 + 36.53 + +### DS3231 β€” Real-Time Clock +- **Address:** 0x68 +- **Read time:** 7 bytes from register 0x00 (seconds, minutes, hours, day, date, month, year) β€” BCD encoded +- **Set time:** Write 7 BCD bytes to register 0x00 +- **Temperature:** 2 bytes from register 0x11 (signed, 0.25Β°C resolution) +- **Status:** Register 0x0F β€” bit 2 = busy, bit 0 = alarm 1 flag + +### INA219 β€” Current & Power Monitor +- **Address:** 0x40-0x4F (A0,A1 pin selectable) +- **Config:** Register 0x00 β€” set voltage range, gain, ADC resolution +- **Shunt voltage:** Register 0x01 (signed 16-bit, LSB=10Β΅V) +- **Bus voltage:** Register 0x02 (bits 15:3, LSB=4mV) +- **Power:** Register 0x03 (after calibration) +- **Current:** Register 0x04 (after calibration) +- **Calibration:** Register 0x05 β€” set based on shunt resistor value + +### PCA9685 β€” 16-Channel PWM / Servo Controller +- **Address:** 0x40-0x7F (A0-A5 selectable, default 0x40) +- **Mode 1:** Register 0x00 β€” bit 4=sleep, bit 5=auto-increment +- **Set PWM freq:** Sleep β†’ write prescale to 0xFE β†’ wake. Prescale = round(25MHz / (4096 Γ— freq)) - 1 +- **Channel N on/off:** Registers 0x06+4*N to 0x09+4*N (ON_L, ON_H, OFF_L, OFF_H) +- **Servo 0Β°-180Β°:** ON=0, OFF=150-600 (at 50Hz). Typical: 0Β°=150, 90Β°=375, 180Β°=600 + +### AT24C256 β€” 256Kbit EEPROM +- **Address:** 0x50-0x57 (A0-A2 selectable) +- **Read:** Write 2-byte address (high, low), then read N bytes +- **Write:** Write 2-byte address + up to 64 bytes (page write), wait 5ms for write cycle +- **Page size:** 64 bytes. Writes that cross page boundary wrap around. + +## SPI Devices + +### MCP3008 β€” 8-Channel 10-bit ADC +- **Interface:** SPI mode 0, max 3.6 MHz @ 5V +- **Read channel N:** Send `[0x01, (0x80 | N<<4), 0x00]`, result in last 10 bits of bytes 1-2 +- **Formula:** value = ((byte[1] & 0x03) << 8) | byte[2] +- **Voltage:** value Γ— Vref / 1024 + +### W25Q128 β€” 128Mbit SPI Flash +- **Interface:** SPI mode 0 or 3, up to 104 MHz +- **Read ID:** Send `[0x9F, 0, 0, 0]` β†’ manufacturer + device ID +- **Read data:** Send `[0x03, addr_high, addr_mid, addr_low]` + N zero bytes +- **Status:** Send `[0x05, 0]` β†’ bit 0 = BUSY