relayhat

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Project

relayhat is a Go daemon that switches BC Robotics Relay HATs (2-channel Pi Zero HAT on GPIO 4/17, 4-channel HAT on GPIO 4/17/22/27) through Raspberry Pi GPIO outputs and exposes them via a TLS REST API with bearer tokens (hashes in a token file). Single binary, cross-compiled for the Pi. It shares its skeleton (lifecycle, config, TLS, Makefile, CI, release) with the sibling projects tadl, s0meter, smartmeter and modbusgateway, with tadl as the template — when changing one of those parts, check whether the others need the same change.

Build / develop

The GPIO dependency (warthog618/go-gpiocdev via womat/gpio/rpi) only compiles for Linux. On macOS go build ./... and go vet ./... fail with undefined: uapi.*. Always set the target explicitly when checking code locally:

GOOS=linux GOARCH=arm64 go vet ./...
GOOS=linux GOARCH=arm64 go build ./...

make lint runs go vet, golangci-lint (v2, default linters; .golangci.yml holds the exclusions, each with its reason) and govulncheck for linux/armv6, each also with -tags swagger where it applies. The tools are built for the host into bin/tools and pinned in the Makefile, because go run under the target’s GOOS/GOARCH would build a binary the host cannot execute. Fix a finding unless it is deliberate; then exclude it narrowly (one function in exclude-functions, or path + linter + text), never by switching a linter off.

make build_arm6        # every Pi in 32-bit mode, Pi 1 / Zero — the default deployment target
make build_arm7        # Pi 2/3/4/5, 32-bit OS
make build_arm64       # Pi 3/4/5/400/Zero2, 64-bit OS
make build_arm6_dev    # + Swagger UI (-tags swagger); _dev variants exist per arch
make deploy            # build for $(PI_ARCH) then scp to $(PI_USER)@$(PI_HOST)
make clean

ensure_dev_certs (a prerequisite of every build target) generates app/certs/dev_{cert,key}.pem if missing; these are //go:embed-ed and gitignored, so a fresh clone must build via make, not bare go build.

Tests use the in-memory GPIO emulator of womat/gpio (gpio/rpiemu) through relay.NewWithPin and the App.openRelay hook, so they need no hardware — but they compile on Linux only, like everything here. Run them with make test (go test -race ./...), on macOS via docker run --rm -v "$PWD":/src -w /src golang:1.27 make test. VERSION (app/app.go), buildDate and buildCommit (cmd/main.go) are all vars injected via -ldflags — never edit them in source. The Makefile derives VERSION from git describe --tags; GoReleaser uses the tag itself.

Releases

There is one branch, main: work is committed to it and a release is a tag on it. make release TAG=vX.Y.Z refuses to run from any other branch, with a dirty tree, or when main and origin/main differ; .github/workflows/release.yml re-checks that the tagged commit is on main, so a hand-made git tag cannot bypass it. Use a short-lived feature branch for work that must not land on main yet. Because releases are tags on the branch everyone builds from, git describe — and so a local build’s version — always sees the latest release.

Versioning is SemVer and the Git tag is the single source of truth. .github/workflows/release.yml runs goreleaser release --clean, which builds linux arm64/armv7/armv6 and publishes a GitHub release with checksums and a grouped changelog. .goreleaser.yaml’s before hook must keep running make ensure_dev_certs (GoReleaser calls go build directly), and archives must keep shipping README.md (third-party license overview), LICENSE (MIT) and contrib/relayhat.service, the sandboxed unit the README installs (root-owned /opt/relayhat, only data/ writable, DeviceAllow=/dev/gpiochip0, RestartPreventExitStatus=78). When adding a dependency, update the license table in README.md.

.github/workflows/ci.yml runs on every push/PR against main: a test job (native, make test) and a build matrix over armv6/armv7/arm64 that vets, builds (also -tags swagger) and runs golangci-lint (also -tags swagger) and govulncheck. All actions are pinned to a commit SHA with the release in a comment, golangci-lint and govulncheck to a version (in ci.yml and the Makefile’s lint target); .github/dependabot.yml updates actions and Go modules weekly, but not the go install pins.

PI_USER/PI_HOST/PI_PATH default to placeholders; the actual device comes from environment variables (set once for all projects; they win over the ?= defaults) or, project-specific, from Makefile.local (gitignored, pulled in via -include). PI_ARCH defaults to arm6: it runs on every Pi in 32-bit mode, and a Pi Zero (1st gen, 2-channel HAT) is ARMv6 only, where an arm64 binary dies with Exec format error. Every deploy* target follows it, so none of them may hardcode an architecture; CI runs a matrix over armv6/armv7/arm64. make deploy is the development loop (binary reports a -dirty version), make deploy_release TAG=vX.Y.Z downloads, verifies and copies a published release.

Screenshots

The README screenshots (docs/screenshots/web-ui*.png) and docs/social-preview.png are rendered from the real app/ui/index.html with a mocked API in headless Chromium. The same script first clicks through the page (login, two-step switch, double tap, Esc, switch lock, rejected token) and fails when it misbehaves — run it after every change to the page. The example relays, host names and addresses are made up; keep real ones out of this public repository. After a visible change upload the social preview again under the repository’s Settings → Social preview (GitHub has no API for it):

docker run --rm -v "$PWD":/src -w /src mcr.microsoft.com/playwright/python:v1.52.0-noble \
  sh -c 'pip install -q playwright==1.52.0 && python3 docs/screenshots/capture.py'

Swagger

Swagger UI is behind the swagger build tag (app/swagger.go vs app/swagger_stub.go, both defining registerSwaggerRoute). Regenerate docs/ from the annotations after changing API handlers:

docs/generate.sh   # must run from the project root; needs swaggo/swag installed

Architecture

Layering is strict: cmd → app → pkg/*. Lower layers never import upward.

app.relays is shared state protected by app.mu: reads under RLock, writes under Lock.

External dependencies github.com/womat/golib/v2 (web: auth, CORS, IP filter, Encode; xlog) and github.com/womat/gpio (gpio, gpio/rpi, and gpio/rpiemu for the tests). They are not vendored — read them in $(go env GOMODCACHE)/github.com/womat/golib/v2@<version> and …/github.com/womat/gpio@<version> when behavior is unclear.

Conventions