zeroclaw/docs/aardvark-integration.md
ehushubhamshaw 5fbce9b4c0 fix(aardvark-sys): diagnose arch mismatch and sign vendor .so for Rosetta 2
Two issues prevented the Aardvark from being detected on Apple Silicon:
1. The vendored x86_64 aardvark.so could not be dlopen-ed by a native arm64
   process (architecture mismatch). Added explicit error messages in lib()
   that identify mach-o incompatible-architecture errors and guide users to
   either download the arm64 SDK from Total Phase or cross-build as x86_64.
2. macOS Gatekeeper blocked dlopen with code-signature policy error for
   the downloaded .so when loaded from an x86_64 Rosetta 2 process.
   Fixed by running xattr -cr + codesign --sign - on the vendor binary.

Verified with a C x86_64 probe (via Rosetta 2) and cargo test -p aardvark-sys
--target x86_64-apple-darwin: library loads, aa_find_devices returns n=1
port=0x0000 (adapter present and free).

Workaround for arm64 hosts until Total Phase ships an arm64 macOS SDK:
  rustup target add x86_64-apple-darwin
  cargo build --features hardware --target x86_64-apple-darwin --bin zeroclaw
  (requires ~8 GB free disk space; wasmtime is an unconditional workspace dep)
2026-03-04 19:09:54 -05:00

11 KiB

Aardvark Integration — How It Works

A plain-language walkthrough of every piece and how they connect.


The Big Picture

┌──────────────────────────────────────────────────────────────┐
│                        STARTUP (boot)                        │
│                                                              │
│  1. Ask aardvark-sys: "any adapters plugged in?"            │
│  2. For each one found → register a device + transport       │
│  3. Load tools only if hardware was found                    │
└──────────────────────────────────────────┬───────────────────┘
                                           │
                    ┌──────────────────────▼──────────────────────┐
                    │              RUNTIME (agent loop)            │
                    │                                              │
                    │  User: "scan i2c bus"                        │
                    │     → agent calls i2c_scan tool              │
                    │     → tool builds a ZcCommand                │
                    │     → AardvarkTransport sends to hardware     │
                    │     → response flows back as text            │
                    └──────────────────────────────────────────────┘

Layer by Layer

Layer 1 — aardvark-sys (the USB talker)

File: crates/aardvark-sys/src/lib.rs

This is the only layer that ever touches the raw C library. Think of it as a thin translator: it turns C function calls into safe Rust.

Algorithm:

find_devices()
  → call aa_find_devices(16, buf)       // ask C lib how many adapters
  → return Vec of port numbers          // [0, 1, ...] one per adapter

open_port(port)
  → call aa_open(port)                  // open that specific adapter
  → if handle ≤ 0, return OpenFailed
  → else return AardvarkHandle{ _port: handle }

i2c_scan(handle)
  → for addr in 0x08..=0x77            // every valid 7-bit address
      try aa_i2c_read(addr, 1 byte)    // knock on the door
      if ACK → add to list             // device answered
  → return list of live addresses

i2c_read(handle, addr, len)
  → aa_i2c_read(addr, len bytes)
  → return bytes as Vec<u8>

i2c_write(handle, addr, data)
  → aa_i2c_write(addr, data)

spi_transfer(handle, bytes_to_send)
  → aa_spi_write(bytes)                // full-duplex: sends + receives
  → return received bytes

gpio_set(handle, direction, value)
  → aa_gpio_direction(direction)       // which pins are outputs
  → aa_gpio_put(value)                 // set output levels

gpio_get(handle)
  → aa_gpio_get()                      // read all pin levels as bitmask

Drop(handle)
  → aa_close(handle._port)            // always close on drop

In stub mode (no SDK): every method returns Err(NotFound) immediately. find_devices() returns []. Nothing crashes.


Layer 2 — AardvarkTransport (the bridge)

File: src/hardware/aardvark.rs

The rest of ZeroClaw speaks a single language: ZcCommandZcResponse. AardvarkTransport translates between that protocol and the aardvark-sys calls above.

Algorithm:

send(ZcCommand) → ZcResponse

  extract command name from cmd.name
  extract parameters from cmd.params (serde_json values)

  match cmd.name:

    "i2c_scan"   → open handle → call i2c_scan()
                   → format found addresses as hex list
                   → return ZcResponse{ output: "0x48, 0x68" }

    "i2c_read"   → parse addr (hex string) + len (number)
                   → open handle → i2c_enable(bitrate)
                   → call i2c_read(addr, len)
                   → format bytes as hex
                   → return ZcResponse{ output: "0xAB 0xCD" }

    "i2c_write"  → parse addr + data bytes
                   → open handle → i2c_write(addr, data)
                   → return ZcResponse{ output: "ok" }

    "spi_transfer" → parse bytes_hex string → decode to Vec<u8>
                     → open handle → spi_enable(bitrate)
                     → spi_transfer(bytes)
                     → return received bytes as hex

    "gpio_set"   → parse direction + value bitmasks
                   → open handle → gpio_set(dir, val)
                   → return ZcResponse{ output: "ok" }

    "gpio_get"   → open handle → gpio_get()
                   → return bitmask value as string

  on any AardvarkError → return ZcResponse{ error: "..." }

Key design choice — lazy open: The handle is opened fresh for every command and dropped at the end. This means no held connection, no state to clean up, and no "is it still open?" logic anywhere.


Layer 3 — Tools (what the agent calls)

File: src/hardware/aardvark_tools.rs

Each tool is a thin wrapper. It:

  1. Validates the agent's JSON input
  2. Resolves which physical device to use
  3. Builds a ZcCommand
  4. Calls AardvarkTransport.send()
  5. Returns the result as text
I2cScanTool.call(args)
  → look up "device" in args (default: "aardvark0")
  → find that device in the registry
  → build ZcCommand{ name: "i2c_scan", params: {} }
  → send to AardvarkTransport
  → return "Found: 0x48, 0x68" (or "No devices found")

I2cReadTool.call(args)
  → require args["addr"] and args["len"]
  → build ZcCommand{ name: "i2c_read", params: {addr, len} }
  → send → return hex bytes

I2cWriteTool.call(args)
  → require args["addr"] and args["data"] (hex or array)
  → build ZcCommand{ name: "i2c_write", params: {addr, data} }
  → send → return "ok" or error

SpiTransferTool.call(args)
  → require args["bytes"] (hex string)
  → build ZcCommand{ name: "spi_transfer", params: {bytes} }
  → send → return received bytes

GpioAardvarkTool.call(args)
  → require args["direction"] + args["value"]  (set)
         OR no extra args                       (get)
  → build appropriate ZcCommand
  → send → return result

DatasheetTool.call(args)
  → action = args["action"]: "search" | "download" | "list" | "read"
  → "search":   return a Google/vendor search URL for the device
  → "download": fetch PDF from args["url"] → save to ~/.zeroclaw/hardware/datasheets/
  → "list":     scan the datasheets directory → return filenames
  → "read":     open a saved PDF and return its text

Layer 4 — Device Registry (the address book)

File: src/hardware/device.rs

The registry is a runtime map of every connected device. Each entry stores: alias, kind, capabilities, transport handle.

register("aardvark", vid=0x2b76, ...)
  → DeviceKind::from_vid(0x2b76)  → DeviceKind::Aardvark
  → DeviceRuntime::from_kind()    → DeviceRuntime::Aardvark
  → assign alias "aardvark0" (then "aardvark1" for second, etc.)
  → store entry in HashMap

attach_transport("aardvark0", AardvarkTransport, capabilities{i2c,spi,gpio})
  → store Arc<dyn Transport> in the entry

has_aardvark()
  → any entry where kind == Aardvark  → true / false

resolve_aardvark_device(args)
  → read "device" param (default: "aardvark0")
  → look up alias in HashMap
  → return (alias, DeviceContext{ transport, capabilities })

Layer 5 — boot() (startup wiring)

File: src/hardware/mod.rs

boot() runs once at startup. For Aardvark:

boot()
  ...
  aardvark_ports = aardvark_sys::AardvarkHandle::find_devices()
  // → [] in stub mode, [0] if one adapter is plugged in

  for (i, port) in aardvark_ports:
    alias = registry.register("aardvark", vid=0x2b76, ...)
    // → "aardvark0", "aardvark1", ...

    transport = AardvarkTransport::new(port, bitrate=100kHz)
    registry.attach_transport(alias, transport, {i2c:true, spi:true, gpio:true})

    log "[registry] aardvark0 ready → Total Phase port 0"
  ...

Layer 6 — Tool Registry (the loader)

File: src/hardware/tool_registry.rs

After boot(), the tool registry checks what hardware is present and loads only the relevant tools:

ToolRegistry::load(devices)

  # always loaded (Pico / GPIO)
  register: gpio_write, gpio_read, gpio_toggle, pico_flash, device_list, device_status

  # only loaded if an Aardvark was found at boot
  if devices.has_aardvark():
    register: i2c_scan, i2c_read, i2c_write, spi_transfer, gpio_aardvark, datasheet

This is why the hardware_feature_registers_all_six_tools test still passes in stub mode — has_aardvark() returns false, 0 extra tools load, count stays at 6.


Full Flow Diagram

 SDK FILES          aardvark-sys            ZeroClaw core
 (vendor/)          (crates/)               (src/)
─────────────────────────────────────────────────────────────────

 aardvark.h  ──►  build.rs         boot()
 aardvark.so       (bindgen)    ──►  find_devices()
                       │                │
                  bindings.rs           │  vec![0]  (one adapter)
                       │                ▼
                  lib.rs           register("aardvark0")
                  AardvarkHandle   attach_transport(AardvarkTransport)
                       │                │
                       │                ▼
                       │         ToolRegistry::load()
                       │           has_aardvark() == true
                       │           → load 6 aardvark tools
                       │
─────────────────────────────────────────────────────────────────

 USER MESSAGE: "scan the i2c bus"

  agent loop
      │
      ▼
  I2cScanTool.call()
      │
      ▼
  resolve_aardvark_device("aardvark0")
      │  returns transport Arc
      ▼
  AardvarkTransport.send(ZcCommand{ name: "i2c_scan" })
      │
      ▼
  AardvarkHandle::open_port(0)    ← opens USB connection
      │
      ▼
  aa_i2c_read(0x08..0x77)         ← probes each address
      │
      ▼
  AardvarkHandle dropped           ← USB connection closed
      │
      ▼
  ZcResponse{ output: "Found: 0x48, 0x68" }
      │
      ▼
  agent sends reply to user: "I found two I2C devices: 0x48 and 0x68"

Stub vs Real Side by Side

Stub mode (now) Real hardware
find_devices() returns [] returns [0]
open_port(0) Err(NotFound) opens USB, returns handle
i2c_scan() [] probes bus, returns addresses
tools loaded only the 6 Pico tools 6 Pico + 6 Aardvark tools
has_aardvark() false true
SDK needed no yes (vendor/aardvark.h + .so)

The only code that changes when you plug in real hardware is inside crates/aardvark-sys/src/lib.rs — every other layer is already wired up and waiting.