Dieser Post ist Teil der Serie über mein privates BeagleBone-Black-Projekt. Die Services-Schicht des Projekts ist in Go geschrieben und stellt Hardware-Peripherie über eine REST API bereit. Damit lassen sich GPIO, I2C und UART von außen steuern — ohne direkt mit dem Board verbunden zu sein.

API-Design

Die API folgt REST-Prinzipien: Ressourcen statt Aktionen, HTTP-Verben mit Bedeutung, JSON als Datenformat.

Endpunkt-Übersicht:

GET    /gpio/{pin}              → Pin-Zustand lesen
PUT    /gpio/{pin}              → Pin-Zustand setzen
GET    /i2c/{bus}/{addr}/{reg}  → I2C-Register lesen
POST   /i2c/{bus}/{addr}        → I2C-Bytes schreiben
GET    /uart/{port}             → UART-Daten lesen (polling)
POST   /uart/{port}             → UART-Daten senden

Keine HATEOAS, kein GraphQL — das ist ein privates Projekt, nicht eine Enterprise-API. Einfach und funktional.

Anbindung an die HAL-Schicht

Go kann keine Rust-Bibliotheken direkt laden — aber C-kompatible Shared Libraries. Rust kompiliert als C-kompatible Library, Go bindet sie via cgo ein.

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"

Der cgo-Kommentarblock wird vom Go-Compiler direkt ausgewertet. LDFLAGS zeigt auf die cross-kompilierten ARM-Libraries.

GPIO über 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)
}

Beispiel-Requests

# Pin 48 lesen (LED auf BeagleBone Black)
curl http://beaglebone:8080/api/v1/gpio/48
# {"pin":48,"value":0}

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

I2C über HTTP

I2C ist etwas komplexer: Bus-Nummer, Device-Adresse und Register-Adresse sind alle Teil des Pfads.

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-Fehler → 502 Bad Gateway (Hardware-Downstream-Fehler)
        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,
    })
}

Fehlerbehandlung bei Bus-Fehlern

Hardware-Fehler sind keine Client-Fehler (4xx) sondern Upstream-Fehler (5xx). Ein nicht reagierendes I2C-Device ist 502 Bad Gateway, eine falsche Adresse im Request ist 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 nicht vorhanden
    case errors.Is(err, ErrI2CBusError):
        return http.StatusBadGateway       // 502: Bus-Fehler
    default:
        return http.StatusInternalServerError
    }
}

Beispiel-Request

# Temperatur-Sensor LM75 auf Bus 1, Adresse 0x48, Register 0x00
curl http://beaglebone:8080/api/v1/i2c/1/0x48/0x00
# {"bus":1,"addr":"0x48","reg":"0x00","value":42}

UART über HTTP

UART über HTTP ist die ungewöhnlichste Kombination. Zwei Varianten: Polling (Request/Response) und Streaming. Für dieses Projekt reicht Polling.

Handler

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

    // Timeout aus 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-kodiert
    }
    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-Daten sind Binärdaten — base64 ist die sicherste Kodierung für JSON. Wer nur ASCII sendet, kann auch direkt einen String nutzen, muss aber mit Encoding-Problemen rechnen.

Beispiel: vollständiger Request-Flow

Wie sieht der Weg eines HTTP-Requests bis zum GPIO-Register aus?

HTTP Client
  GET /api/v1/gpio/48
    │
    ▼
Go Router (chi)
  GPIOHandler.Get(w, r)
    │
    ▼
hal.GPIORead(48)                    ← Go-Funktion
    │
    ▼
hal_gpio_read(48, &value)           ← cgo-Aufruf → Rust pub extern "C"
    │
    ▼
gpio_read_sysfs(48, &value)         ← Rust ruft C-Treiber auf (unsafe)
    │
    ▼
open("/sys/class/gpio/gpio48/value") ← C, Linux sysfs
    │
    ▼
AM335x GPIO-Register

Jede Schicht hat eine klar definierte Aufgabe. Go kennt keine Dateinamen, C kennt keine HTTP-Handler.

Deployment als 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

# Hardware-Zugriff erlauben
SupplementaryGroups=gpio i2c dialout

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

Nächster Post in der Serie: Rust in der HAL — warum und wie