This post is part of the series about my private BeagleBone Black project. The project’s services layer is written in Go and exposes hardware peripherals over a REST API. This makes it possible to control GPIO, I2C, and UART from the outside — without being directly connected to the board.

API Design

The API follows REST principles: resources instead of actions, HTTP verbs with meaning, JSON as the data format.

Endpoint overview:

GET    /gpio/{pin}              → read pin state
PUT    /gpio/{pin}              → set pin state
GET    /i2c/{bus}/{addr}/{reg}  → read I2C register
POST   /i2c/{bus}/{addr}        → write I2C bytes
GET    /uart/{port}             → read UART data (polling)
POST   /uart/{port}             → send UART data

No HATEOAS, no GraphQL — this is a private project, not an enterprise API. Simple and functional.

Connecting to the HAL Layer

Go can’t load Rust libraries directly — but it can load C-compatible shared libraries. Rust compiles as a C-compatible library, and Go binds it via cgo.

package hal

// #cgo LDFLAGS: -L${SRCDIR}/../../rust/target/armv7-unknown-linux-gnueabihf/release -lbbb_hal
// #cgo LDFLAGS: -L${SRCDIR}/../../c/build-arm -lbbb_drivers
// #include "bbb_hal.h"
import "C"
import "unsafe"

The cgo comment block is evaluated directly by the Go compiler. LDFLAGS points to the cross-compiled ARM libraries.

GPIO over HTTP

Handler

type GPIOHandler struct {
    hal *hal.HAL
}

func (h *GPIOHandler) Get(w http.ResponseWriter, r *http.Request) {
    pin, err := strconv.Atoi(chi.URLParam(r, "pin"))
    if err != nil {
        http.Error(w, "invalid pin", http.StatusBadRequest)
        return
    }

    value, err := h.hal.GPIORead(pin)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    json.NewEncoder(w).Encode(map[string]any{
        "pin":   pin,
        "value": value,
    })
}

func (h *GPIOHandler) Put(w http.ResponseWriter, r *http.Request) {
    pin, _ := strconv.Atoi(chi.URLParam(r, "pin"))

    var body struct {
        Value int `json:"value"`
    }
    if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
        http.Error(w, "invalid body", http.StatusBadRequest)
        return
    }
    if body.Value != 0 && body.Value != 1 {
        http.Error(w, "value must be 0 or 1", http.StatusBadRequest)
        return
    }

    if err := h.hal.GPIOWrite(pin, body.Value); err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    w.WriteHeader(http.StatusNoContent)
}

Example Requests

# Read pin 48 (LED on the BeagleBone Black)
curl http://beaglebone:8080/api/v1/gpio/48
# {"pin":48,"value":0}

# Set pin 48 to HIGH
curl -X PUT http://beaglebone:8080/api/v1/gpio/48 \
  -H "Content-Type: application/json" \
  -d '{"value": 1}'
# 204 No Content

I2C over HTTP

I2C is a bit more complex: bus number, device address, and register address are all part of the path.

Handler

func (h *I2CHandler) Get(w http.ResponseWriter, r *http.Request) {
    bus,  _ := strconv.Atoi(chi.URLParam(r, "bus"))
    addr, _ := strconv.ParseUint(chi.URLParam(r, "addr"), 0, 8)  // hex: 0x48
    reg,  _ := strconv.ParseUint(chi.URLParam(r, "reg"),  0, 8)

    value, err := h.hal.I2CRead(bus, uint8(addr), uint8(reg))
    if err != nil {
        // I2C error → 502 Bad Gateway (hardware downstream error)
        http.Error(w, err.Error(), http.StatusBadGateway)
        return
    }

    json.NewEncoder(w).Encode(map[string]any{
        "bus":   bus,
        "addr":  fmt.Sprintf("0x%02x", addr),
        "reg":   fmt.Sprintf("0x%02x", reg),
        "value": value,
    })
}

Error Handling for Bus Errors

Hardware errors are not client errors (4xx) but upstream errors (5xx). A non-responding I2C device is 502 Bad Gateway, an invalid address in the request is 400 Bad Request.

var (
    ErrI2CDeviceNotFound = errors.New("i2c device not found")
    ErrI2CBusError       = errors.New("i2c bus error")
)

func httpStatusForHALError(err error) int {
    switch {
    case errors.Is(err, ErrI2CDeviceNotFound):
        return http.StatusNotFound         // 404: device not found
    case errors.Is(err, ErrI2CBusError):
        return http.StatusBadGateway       // 502: bus error
    default:
        return http.StatusInternalServerError
    }
}

Example Request

# Temperature sensor LM75 on bus 1, address 0x48, register 0x00
curl http://beaglebone:8080/api/v1/i2c/1/0x48/0x00
# {"bus":1,"addr":"0x48","reg":"0x00","value":42}

UART over HTTP

UART over HTTP is the most unusual combination. Two variants: polling (request/response) and streaming. For this project, polling is enough.

Handler

func (h *UARTHandler) Get(w http.ResponseWriter, r *http.Request) {
    port := chi.URLParam(r, "port")  // "ttyO1", "ttyO2", etc.

    // timeout from query parameter, default 100ms
    timeout := 100 * time.Millisecond
    if t := r.URL.Query().Get("timeout"); t != "" {
        if d, err := time.ParseDuration(t); err == nil {
            timeout = d
        }
    }

    data, err := h.hal.UARTRead(port, timeout)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadGateway)
        return
    }

    json.NewEncoder(w).Encode(map[string]any{
        "port": port,
        "data": base64.StdEncoding.EncodeToString(data),
        "len":  len(data),
    })
}

func (h *UARTHandler) Post(w http.ResponseWriter, r *http.Request) {
    port := chi.URLParam(r, "port")

    var body struct {
        Data string `json:"data"`  // base64-encoded
    }
    if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
        http.Error(w, "invalid body", http.StatusBadRequest)
        return
    }

    raw, err := base64.StdEncoding.DecodeString(body.Data)
    if err != nil {
        http.Error(w, "data must be base64", http.StatusBadRequest)
        return
    }

    if err := h.hal.UARTWrite(port, raw); err != nil {
        http.Error(w, err.Error(), http.StatusBadGateway)
        return
    }

    w.WriteHeader(http.StatusNoContent)
}

UART data is binary data — base64 is the safest encoding for JSON. If you only send ASCII, you can also use a plain string directly, but you have to expect encoding issues.

Example: Complete Request Flow

What does the path of an HTTP request to the GPIO register look like?

HTTP Client
  GET /api/v1/gpio/48
    │
    ▼
Go Router (chi)
  GPIOHandler.Get(w, r)
    │
    ▼
hal.GPIORead(48)                    ← Go function
    │
    ▼
hal_gpio_read(48, &value)           ← cgo call → Rust pub extern "C"
    │
    ▼
gpio_read_sysfs(48, &value)         ← Rust calls the C driver (unsafe)
    │
    ▼
open("/sys/class/gpio/gpio48/value") ← C, Linux sysfs
    │
    ▼
AM335x GPIO register

Each layer has a clearly defined responsibility. Go doesn’t know about file names, C doesn’t know about HTTP handlers.

Deployment as a systemd Service

# /etc/systemd/system/bbb-hal.service
[Unit]
Description=BeagleBone Black HAL REST API
After=network.target

[Service]
Type=simple
User=pi
WorkingDirectory=/opt/bbb-hal
ExecStart=/opt/bbb-hal/bbb-api --port 8080
Restart=always
RestartSec=5

# allow hardware access
SupplementaryGroups=gpio i2c dialout

[Install]
WantedBy=multi-user.target
sudo systemctl enable --now bbb-hal
sudo systemctl status bbb-hal

Next post in the series: Rust in the HAL — why and how