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.
Base URL: http://beaglebone:8080/api/v1
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 dataNo 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 ContentI2C 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 registerEach 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.targetsudo systemctl enable --now bbb-hal
sudo systemctl status bbb-halNext post in the series: Rust in the HAL — why and how