Guidance for Claude Code (and other AI coding agents) when working in this repository.
go-hardware-driver is a small C daemon that runs on GOcontroll Moduline embedded controllers (aarch64 Linux). At boot it reads /lib/firmware/gocontroll/modules.json, instantiates a per-slot module driver, and runs a fixed 100 Hz tick loop forever. Per-channel input values are published, and per-channel output commands are read, via plain ASCII files under /dev/shm/gocontroll/. The shm layout is the integration contract for downstream apps (Node-RED, Simulink-generated controllers, fleet apps, custom scripts) — they treat the driver as a hardware-abstraction layer.
It is one of three tightly coupled packages on the controller; understanding the boundaries matters:
| Package | Responsibility | Key persisted artifact |
|---|---|---|
go-modules |
Scan SPI bus, identify modules, flash firmware, write modules.json |
/lib/firmware/gocontroll/modules.json |
go-hardware-driver (this repo) |
Read modules.json and run the per-slot module driver loop (100 Hz) |
/dev/shm/gocontroll/slot{N}/... |
go-web-ui |
Browser-based config editor + status view | reads/writes modules.json |
modules.json is the single source of truth. The JSON schema is owned by go-modules and documented in GOcontroll-Architecture/modules/configuration.md — this driver is a strict consumer.
Cross-compile target is aarch64-unknown-linux-gnu using aarch64-linux-gnu-gcc. Vendor sources come from the GOcontroll C support codebase (the GOcontroll-CodeBase repo). The Makefile expects GO_BASE to point at a checkout of that repo:
make GO_BASE=/path/to/GOcontroll-CodeBaseOutput: build/go-hardware-driver.
For ad-hoc on-controller testing, building natively works too if libjson-c-dev, libiio-dev and gcc are installed; the cross-compiler flags can be overridden:
make GO_BASE=/path/to/codebase CC=gcc \
CPPFLAGS="-Iinclude -I.../codebase/code -I.../codebase/code/modules -I/usr/include/json-c -DGOCONTROLL_LINUX -D_GNU_SOURCE -MMD -MP" \
LDFLAGS="" LDLIBS="-ljson-c -liio -lrt -lpthread -lm"The CI workflow (.github/workflows/build-package.yml) triggers on v* tags and produces an arm64 .deb plus a GitHub Release. Tag drives the version — the matching debian/changelog entry must be updated in the same commit.
main()installs signal handlers, callsshm_base_init()(creates/dev/shm/gocontroll/), andGO_board_get_hardware_version()(single per-process call — see "GOcontroll-CodeBase quirks" below).config_load("/lib/firmware/gocontroll/modules.json", …)parses the JSON. Schema must be1.x.x—1.xis forwards-compatible; major-version mismatch refuses to load. Empty / unknown / duplicate slots are tolerated (warning + skip) so the driver keeps running.- For each non-empty slot the driver looks up the module-type ops via
registry_lookup(). Type-name strings must match the JSON"module_type"field exactly. - The per-slot
init()is called. If init fails, the slot is skipped — the driver does not abort. This is important: a single misconfigured or unhealthy slot must never bring down the whole driver. - The main loop ticks every 10 ms (
CLOCK_MONOTONIC, absolute deadlines viaclock_nanosleepwithTIMER_ABSTIME). Each tick walks the active drivers and callstick().EINTRduring the sleep is tolerated unless a stop signal arrived. - On
SIGINT/SIGTERMthe loop exits cleanly, each driver'sshutdown()is called.
Drivers implement a small ops table:
struct driver_ops {
const char *type; /* matches modules.json "module_type" */
int (*init)(struct driver *drv, const struct slot_cfg *cfg);
int (*tick)(struct driver *drv);
void (*shutdown)(struct driver *drv);
};Concrete driver files live under src/modules/. A new driver must:
- Implement the four ops (or stub them — see
src/modules/ir_communication.cfor the canonical stub pattern that fails init with a clear message until full SPI support lands). - Export the ops table as
const struct driver_ops <name>_ops. - Register it in
src/registry.c(addexterndeclaration + entry inTABLE). - Add the source file to
OWN_SRCSin theMakefile.
The type string must exactly match the module_type written by go-modules. If the JSON name changes, both repos must be updated in lockstep.
/dev/shm/gocontroll/
├── slot{N}/
│ ├── channel{C}/
│ │ ├── value # input: measured value (driver writes)
│ │ │ # output: command (host writes)
│ │ ├── reset_value # input only: pulse-counter reset value (host writes)
│ │ ├── reset_trigger # input only: edge-triggered reset (host writes)
│ │ ├── current # output feedback (mA, driver writes)
│ │ └── duty # output feedback (driver writes)
│ ├── temperature # module-level (output modules)
│ ├── ground
│ ├── supply
│ └── error_code # output-10ch also has total_current
└── <name> -> slot{N}/channel{C}/value (alias when channel.name is given)
All values are ASCII text (%d\n). Single atomic writes via pwrite(fd, buf, n, 0) — readers see either the old or the new value, never a mix. Downstream consumers should open() + pread() per sample (cheap on a tmpfs); the driver does the same. Don't switch to any binary or shared-memory layout without coordinating across all downstream tooling.
For input modules, each channel publishes reset_value (int32) and reset_trigger (uint8). The host requests a counter reset by:
- Writing the desired counter value to
reset_value. - Changing the byte in
reset_triggerto a new value (any change = one reset pulse; e.g. monotonically increment).
The driver reads both each tick and delegates to the codebase pulse-counter reset helper, which only fires an SPI reset when the trigger byte changed since last seen — edge-triggered semantics, so the host does not have to self-clear. On driver restart both files are truncated to avoid a stale trigger from the previous run racing the fresh pulscounterResetTrigger=0 in the codebase and causing a spurious reset.
The systemd unit starts the driver during sysinit.target (before multi-user.target) with:
DefaultDependencies=no— bypass default ordering and hardening for minimum startup latency.Nice=-20— highest priority forSCHED_OTHER.CPUAffinity=3— pin on CPU 3, the same core asgo-simulink. On the M1 (4 cores) CPUs 0–2 stay free for general system load; all real-time loops cluster on one isolated core for cache locality and predictable scheduling.
Don't add scheduling-class changes (SCHED_FIFO/SCHED_RR) without coordinating with go-simulink — they share a core.
This driver only requires schema-major 1. The fields it reads:
- Top-level:
schema_version,controller,slots[]. - Per-slot:
slot,module_type,enabled, plus opaquemoduleobject andchannels[]array. The latter two are parsed inside the per-module driver —config.ckeeps them as opaquejson_object *pointers owned by the root document.
Tolerant parsing:
- Empty slots (no
module_type) → warning + skipped, driver continues. - Unknown
module_type→ warning + skipped, driver continues. - Duplicate slot numbers → first occurrence wins, duplicates dropped with a warning.
enabledmissing → defaults totrue(so oldermodules.jsonkeeps the slot active).
If you make the parser stricter, the M1 / L4 / HMI fleet in the field will start refusing to boot — don't do that without a controller-side migration plan and lining up the other two packages.
Per-slot enabled: bool (added with go-modules 3.2.0 / driver 0.2.0). When false, main.c skips the slot completely — no reset pulse, no bootloader skip, no init(), no tick. The user uses this to hand a module to an external app that wants to drive it directly. Make sure new behaviour in main.c respects this: if you add reset-line wiggling or board-level setup that touches the slot before the per-driver init(), it must also be gated by sc->enabled.
The driver pulls in code/GO_board.c, code/GO_communication_modules.c, the per-module helpers under code/modules/, and supporting bits (print.c, GO_xcp.c, XcpStack.c, GO_memory.c, GO_fault.c, GO_controller_info.c). Things to know when touching code that interacts with them:
GO_board_get_hardware_version()is called exactly once inmain()because it detects the controller and zeroes the globalmoduleOccupancytable as a side-effect. Calling it per-driver would wipe registrations of already-initialized slots. Don't move it.moduleOccupancyand the module-set / module-slot helpers in the codebase carry process-wide state; the driver assumes single-threaded access (the 100 Hz tick loop is the only writer). Don't add background threads without working out the locking.- Some module helpers
exit(-1)on bad arguments (slot out of range, contested slot). The driver itself never callsexit()— it skips the bad slot and keeps running. When using new codebase functions, check for exit paths and either gate the call or accept the process-level failure.
- One slot's failure must never bring down the driver. Every per-driver init/tick error path skips and continues. Preserve that property.
- Schema-tolerant parsing. Missing optional fields, unknown module types, duplicate slots: warn + carry on. Don't add hard-fail paths in
config.c. - Atomic single-byte/word
pwrite()for shm. No partial writes, no truncation, no rename-dance. Readers expect to see either the old or the new value. - Per-driver state in the ops struct's
statepointer. Don't add globals to the per-module C files — slot 2 and slot 5 may run the same driver type at the same time. - Versioning.
debian/changelogdrives the version. TagvX.Y.Zmatches the top entry. Major bumps are the right signal when changing the shm layout or themodules.jsonschema in an incompatible way. - No interactive prompts. This is a systemd-managed daemon — no stdin, no
getchar(), no TUI.
The authoritative module schemas live in the GOcontroll-Architecture repo under modules/:
configuration.md—modules.jsonJSON schema and runtime contractnaming.md— article-number format + module-type enumerationpinning.md— per-module pin assignments per controller slotspi.md— module SPI protocol referenceinput-6ch.md,input-10ch.md,input-4-20ma.md,bridge-2ch.md,output-6ch.md,output-10ch.md,ir-communication.md— per-module specs (channel configs, wire-encoding, defaults, open questions)
When changing how the driver consumes modules.json or what it writes to /dev/shm/gocontroll/, update the matching architecture document(s) in the same change.