diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 706afae..cb11382 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,25 @@ jobs: echo "coverage=$pct" >> "$GITHUB_OUTPUT" echo "tests passed: $passed | sim line coverage: ${pct}%" + mcdc: + # 100% MC/DC (condition coverage) enforced on every Tier-1 driver harness. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: install gcc-14 + run: sudo apt-get update -qq && sudo apt-get install -y -qq gcc-14 + - name: enforce 100% MC/DC on drivers/*/test + run: | + fail=0; ran=0 + for t in drivers/*/test; do + [ -f "$t/Makefile" ] || continue + ran=1 + echo "== MC/DC: $t ==" + make -C "$t" check CC=gcc-14 GCOV=gcov-14 || fail=1 + done + [ "$ran" = 1 ] || { echo "no driver MC/DC harnesses found"; exit 1; } + exit $fail + badges: needs: [test] if: github.event_name == 'push' && github.ref == 'refs/heads/bringup' diff --git a/.gitignore b/.gitignore index ffe69c5..dd0a7de 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ target/ *.swp *~ .DS_Store + +# driver MC/DC harness build dirs +**/test/build/ diff --git a/drivers/relays/relays.c b/drivers/relays/relays.c new file mode 100644 index 0000000..924b7be --- /dev/null +++ b/drivers/relays/relays.c @@ -0,0 +1,132 @@ +#include "relays.h" + +#include +#include +#include +#include + +/* GPIO1_A0 and GPIO1_A1; gpiochip1 base is 32. See relays.h. */ +static const int s_gpio[WARDEN_RELAY_COUNT] = { 32, 33 }; +static const char * const s_name[WARDEN_RELAY_COUNT] = { "Relay 1", "Relay 2" }; + +/* ---- default sysfs backend (Tier-2 plumbing; covered by integration tests) ---- */ + +static const char * gpio_root(void) +{ + const char * r = getenv("WARDEN_GPIO_ROOT"); + return (r && *r) ? r : "/sys/class/gpio"; +} + +static bool sysfs_exists(const char * path) +{ + struct stat st; + return stat(path, &st) == 0; +} + +static void sysfs_write(const char * path, const char * value) +{ + FILE * f = fopen(path, "w"); + if(!f) return; + fputs(value, f); + fclose(f); +} + +static bool sysfs_read_line(const char * path, char * buf, size_t n) +{ + FILE * f = fopen(path, "r"); + if(!f) return false; + bool ok = fgets(buf, (int)n, f) != NULL; + fclose(f); + return ok; +} + +static bool sysfs_read_int(const char * path, int * out) +{ + FILE * f = fopen(path, "r"); + if(!f) return false; + bool ok = fscanf(f, "%d", out) == 1; + fclose(f); + return ok; +} + +static const struct relay_io s_sysfs_io = { + sysfs_exists, sysfs_write, sysfs_read_line, sysfs_read_int +}; + +static const struct relay_io * s_io = &s_sysfs_io; + +void warden_relay__set_io(const struct relay_io * io) { s_io = io ? io : &s_sysfs_io; } + +/* ---- decision logic (Tier-1; measured to 100% MC/DC via the fake io) ---- */ + +const char * warden_relay_name(uint32_t idx) +{ + return idx < WARDEN_RELAY_COUNT ? s_name[idx] : "-"; +} + +static bool exported(int gpio) +{ + char p[80]; + snprintf(p, sizeof(p), "%s/gpio%d", gpio_root(), gpio); + return s_io->exists(p); +} + +/** + * Export and set the direction, without disturbing the level. "out" on an + * unexported pin latches the kernel's default low, which would drop a closed + * relay the first time this page is opened; reading the current level first and + * writing it straight back makes the export transparent. + */ +static bool ensure_ready(int gpio) +{ + if(!exported(gpio)) { + char ex[80], n[16]; + snprintf(ex, sizeof(ex), "%s/export", gpio_root()); + snprintf(n, sizeof(n), "%d", gpio); + s_io->write(ex, n); + if(!exported(gpio)) return false; + } + + char p[96]; + snprintf(p, sizeof(p), "%s/gpio%d/direction", gpio_root(), gpio); + char dir[16] = ""; + if(!s_io->read_line(p, dir, sizeof(dir))) return false; + + if(strncmp(dir, "out", 3) != 0) { + /* Preserve the level across the switch to output. */ + char vp[96]; + int cur; + snprintf(vp, sizeof(vp), "%s/gpio%d/value", gpio_root(), gpio); + if(!s_io->read_int(vp, &cur)) cur = 0; + s_io->write(p, cur ? "high" : "low"); + } + return true; +} + +bool warden_relay_available(uint32_t idx) +{ + if(idx >= WARDEN_RELAY_COUNT) return false; + return ensure_ready(s_gpio[idx]); +} + +bool warden_relay_get(uint32_t idx) +{ + if(idx >= WARDEN_RELAY_COUNT) return false; + if(!exported(s_gpio[idx])) return false; + + char p[96]; + int v; + snprintf(p, sizeof(p), "%s/gpio%d/value", gpio_root(), s_gpio[idx]); + if(!s_io->read_int(p, &v)) v = 0; + return v != 0; +} + +void warden_relay_set(uint32_t idx, bool on) +{ + if(idx >= WARDEN_RELAY_COUNT) return; + if(!ensure_ready(s_gpio[idx])) return; + + char p[96]; + snprintf(p, sizeof(p), "%s/gpio%d/value", gpio_root(), s_gpio[idx]); + s_io->write(p, on ? "1" : "0"); +} diff --git a/drivers/relays/relays.h b/drivers/relays/relays.h new file mode 100644 index 0000000..0ea97a0 --- /dev/null +++ b/drivers/relays/relays.h @@ -0,0 +1,55 @@ +/** + * The two onboard relays (86-Panel bottom board). + * + * RELAY1 <- GPIO1_A0 (sysfs gpio 32) + * RELAY2 <- GPIO1_A1 (sysfs gpio 33) + * + * gpiochip1 base is 32; A0..A7 are offsets 0..7. Nothing in the DT claims these + * (there is no relay driver), so we drive them through /sys/class/gpio directly. + * An off-by-one here would toggle the neighbouring RS485 pair (GPIO1_B0/B1) + * under a running Modbus master, so the numbers come from the schematic, not + * inference. State is never assumed at startup: a relay may be holding a + * contactor closed, and deciding it should be off because we just booted is not + * this module's call. + * + * Hardened for warden-sdk: the sysfs plumbing sits behind a `relay_io` seam so + * the decision logic runs and is measured to 100% MC/DC on the host, and the + * gpio root is `$WARDEN_GPIO_ROOT`-overridable so the real backend can run + * against a scratch tree in an integration test. + */ +#ifndef WARDEN_RELAYS_H +#define WARDEN_RELAYS_H + +#include +#include +#include + +#define WARDEN_RELAY_COUNT 2 + +/** Display name for @p idx, e.g. "Relay 1"; "-" if out of range. */ +const char * warden_relay_name(uint32_t idx); + +/** Current output state, false if it cannot be read. */ +bool warden_relay_get(uint32_t idx); + +/** Drive the output. Exports and sets the direction on first use. */ +void warden_relay_set(uint32_t idx, bool on); + +/** True if the GPIO is exported and usable — the page says so if it is not. */ +bool warden_relay_available(uint32_t idx); + +/* --- Hardware-abstraction seam --------------------------------------------- + * The four filesystem primitives the relay logic needs. Production binds the + * sysfs backend (the default); a unit test binds an in-memory fake that can + * model export -> node-appears, which a passive scratch tree cannot. */ +struct relay_io { + bool (*exists)(const char *path); /* stat(path)==0 */ + void (*write)(const char *path, const char *value); /* best-effort */ + bool (*read_line)(const char *path, char *buf, size_t n); /* a line was read */ + bool (*read_int)(const char *path, int *out); /* one int parsed */ +}; + +/** Swap the io backend (test hook). Pass NULL to restore the sysfs default. */ +void warden_relay__set_io(const struct relay_io *io); + +#endif /* WARDEN_RELAYS_H */ diff --git a/drivers/relays/test/Makefile b/drivers/relays/test/Makefile new file mode 100644 index 0000000..58ed59e --- /dev/null +++ b/drivers/relays/test/Makefile @@ -0,0 +1,41 @@ +# MC/DC unit harness for drivers/relays/relays.c. +# +# make check — build, run, and FAIL unless relays.c hits 100% MC/DC +# (condition) coverage and every unit check passes. +# make report — per-condition gcov annotation (build/relays.c.gcov). +# make clean +# +# Requires gcc >= 14 (for -fcondition-coverage) and its matching gcov. +CC ?= gcc +GCOV ?= gcov +CFLAGS := -O0 -g -Wall -Wextra -I.. +COVFLAGS := --coverage -fcondition-coverage +BUILD := build + +.PHONY: check report clean +.DEFAULT_GOAL := check + +$(BUILD): + @mkdir -p $(BUILD) + +# relays.c carries the coverage flags; the test driver is plain. +$(BUILD)/test: test_relays.c ../relays.c ../relays.h | $(BUILD) + @$(CC) $(CFLAGS) $(COVFLAGS) -c ../relays.c -o $(BUILD)/relays.o + @$(CC) $(CFLAGS) -c test_relays.c -o $(BUILD)/test_relays.o + @$(CC) $(COVFLAGS) $(BUILD)/relays.o $(BUILD)/test_relays.o -o $(BUILD)/test + +check: $(BUILD)/test + @echo "== running relays MC/DC harness ==" + @rm -f $(BUILD)/relays.gcda + @$(BUILD)/test; echo $$? > $(BUILD)/test.rc + @echo + @echo "== MC/DC (condition) coverage of relays.c ==" + @$(GCOV) --conditions --branch-probabilities -o $(BUILD) ../relays.c >$(BUILD)/gcov.log 2>&1 || true + @mv -f *.gcov $(BUILD)/ 2>/dev/null || true + @bash enforce-mcdc.sh $(BUILD)/gcov.log $(BUILD)/relays.c.gcov $(BUILD)/test.rc + +report: check + @grep -nE "condition.*not covered|conditions covered" $(BUILD)/relays.c.gcov || true + +clean: + @rm -rf $(BUILD) diff --git a/drivers/relays/test/enforce-mcdc.sh b/drivers/relays/test/enforce-mcdc.sh new file mode 100755 index 0000000..4fdb4ed --- /dev/null +++ b/drivers/relays/test/enforce-mcdc.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Gate: fail unless every unit check passed AND relays.c reached 100% MC/DC +# (condition) coverage with no uncovered executable lines. +# $1 = gcov stdout log $2 = relays.c.gcov $3 = test exit-code file +set -uo pipefail +LOG="$1"; GCOV="$2"; RCFILE="$3" +rc=0 + +testrc="$(cat "$RCFILE" 2>/dev/null || echo 1)" +if [ "$testrc" != "0" ]; then + echo "RESULT: unit checks FAILED (test exit $testrc)"; rc=1 +else + echo "RESULT: all unit checks passed" +fi + +if [ ! -f "$GCOV" ]; then + echo "RESULT: no coverage file ($GCOV) produced"; exit 1 +fi + +notcov="$(grep -nE "condition[s]? .*not covered" "$GCOV" || true)" +uncov_lines="$(grep -nE "^ +#####:" "$GCOV" || true)" + +# Summary lines for relays.c from gcov stdout. Match the exact file so +# 'test_relays.c' (which also contains "relays.c") is NOT picked up. +FMATCH="File '([^']*/)?relays[.]c'" +cond_line="$(awk -v patt="$FMATCH" '$0 ~ patt {f=1} f&&/Condition outcomes covered:/{print; f=0}' "$LOG")" +line_line="$(awk -v patt="$FMATCH" '$0 ~ patt {f=1} f&&/Lines executed:/{print; f=0}' "$LOG")" +echo " ${line_line:-Lines executed: (n/a)}" +echo " ${cond_line:-Condition outcomes covered: (n/a)}" + +if [ -n "$notcov" ]; then + echo "RESULT: MC/DC gaps (conditions not covered):" + echo "$notcov" | sed 's/^/ /' + rc=1 +fi +if [ -n "$uncov_lines" ]; then + echo "RESULT: uncovered executable lines in relays.c:" + echo "$uncov_lines" | sed 's/^/ /' + rc=1 +fi + +if ! echo "$cond_line" | grep -q "100.00%"; then + echo "RESULT: condition coverage is below 100%" + rc=1 +fi + +[ "$rc" = "0" ] && echo "RESULT: 100% MC/DC + all checks green ✓" +exit "$rc" diff --git a/drivers/relays/test/test_relays.c b/drivers/relays/test/test_relays.c new file mode 100644 index 0000000..1ef90dd --- /dev/null +++ b/drivers/relays/test/test_relays.c @@ -0,0 +1,218 @@ +/* MC/DC harness for drivers/relays/relays.c. + * + * Two layers, one binary, so the combined run covers every decision in relays.c: + * 1. unit tests through a FAKE relay_io — exercise the decision logic, incl. + * the export->node-appears path a passive tree cannot model. + * 2. integration tests through the real sysfs backend + $WARDEN_GPIO_ROOT + * pointed at a scratch tree — exercise the backend's fopen/stat branches. + */ +#include "../relays.h" + +#include +#include +#include +#include +#include +#include + +static int g_fail = 0, g_checks = 0; +#define EXPECT(cond) do { \ + g_checks++; \ + if(!(cond)) { g_fail++; fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); } \ +} while(0) + +/* ---------------- layer 1: fake io ---------------- */ + +static struct { + bool exists_ret; + bool export_makes_exist; /* write(.../export) flips exists_ret true */ + bool read_line_ok; + const char *read_line_val; + bool read_int_ok; + int read_int_val; + char last_write_path[160]; + char last_write_val[32]; +} fk; + +static bool fk_exists(const char *p) { (void)p; return fk.exists_ret; } +static void fk_write(const char *p, const char *v) { + snprintf(fk.last_write_path, sizeof fk.last_write_path, "%s", p); + snprintf(fk.last_write_val, sizeof fk.last_write_val, "%s", v); + if(fk.export_makes_exist && strstr(p, "/export")) fk.exists_ret = true; +} +static bool fk_read_line(const char *p, char *b, size_t n) { + (void)p; + if(!fk.read_line_ok) return false; + snprintf(b, n, "%s", fk.read_line_val); + return true; +} +static bool fk_read_int(const char *p, int *o) { + (void)p; + if(!fk.read_int_ok) return false; + *o = fk.read_int_val; + return true; +} +static const struct relay_io FAKE = { fk_exists, fk_write, fk_read_line, fk_read_int }; + +static void fk_reset(void) { memset(&fk, 0, sizeof fk); } + +static void unit_tests(void) +{ + warden_relay__set_io(&FAKE); + + /* warden_relay_name: both sides of idx < COUNT */ + EXPECT(strcmp(warden_relay_name(0), "Relay 1") == 0); + EXPECT(strcmp(warden_relay_name(WARDEN_RELAY_COUNT), "-") == 0); + + /* available: idx >= COUNT (true) */ + EXPECT(warden_relay_available(WARDEN_RELAY_COUNT) == false); + + /* ensure_ready: not exported -> export FAILS (inner if true) */ + fk_reset(); + fk.exists_ret = false; fk.export_makes_exist = false; + EXPECT(warden_relay_available(0) == false); + EXPECT(strstr(fk.last_write_path, "/export") != NULL); /* export was attempted */ + + /* ensure_ready: not exported -> export SUCCEEDS -> direction read FAILS */ + fk_reset(); + fk.exists_ret = false; fk.export_makes_exist = true; fk.read_line_ok = false; + EXPECT(warden_relay_available(0) == false); + + /* already exported -> direction == "out" -> no level-preserve write */ + fk_reset(); + fk.exists_ret = true; fk.read_line_ok = true; fk.read_line_val = "out\n"; + EXPECT(warden_relay_available(0) == true); + + /* exported -> direction "in" -> read_int FAILS -> cur=0 -> write "low" */ + fk_reset(); + fk.exists_ret = true; fk.read_line_ok = true; fk.read_line_val = "in\n"; + fk.read_int_ok = false; + EXPECT(warden_relay_available(0) == true); + EXPECT(strcmp(fk.last_write_val, "low") == 0); + + /* exported -> direction "in" -> read_int OK, cur=0 -> ternary false -> "low" */ + fk_reset(); + fk.exists_ret = true; fk.read_line_ok = true; fk.read_line_val = "in\n"; + fk.read_int_ok = true; fk.read_int_val = 0; + EXPECT(warden_relay_available(0) == true); + EXPECT(strcmp(fk.last_write_val, "low") == 0); + + /* exported -> direction "in" -> read_int OK, cur=1 -> ternary true -> "high" */ + fk_reset(); + fk.exists_ret = true; fk.read_line_ok = true; fk.read_line_val = "in\n"; + fk.read_int_ok = true; fk.read_int_val = 1; + EXPECT(warden_relay_available(0) == true); + EXPECT(strcmp(fk.last_write_val, "high") == 0); + + /* get: idx >= COUNT (true) */ + EXPECT(warden_relay_get(WARDEN_RELAY_COUNT) == false); + + /* get: not exported -> false */ + fk_reset(); fk.exists_ret = false; + EXPECT(warden_relay_get(0) == false); + + /* get: exported, read_int FAILS -> v=0 -> v!=0 false */ + fk_reset(); fk.exists_ret = true; fk.read_int_ok = false; + EXPECT(warden_relay_get(0) == false); + + /* get: exported, read_int OK v=1 -> v!=0 true */ + fk_reset(); fk.exists_ret = true; fk.read_int_ok = true; fk.read_int_val = 1; + EXPECT(warden_relay_get(0) == true); + + /* get: exported, read_int OK v=0 -> v!=0 false */ + fk_reset(); fk.exists_ret = true; fk.read_int_ok = true; fk.read_int_val = 0; + EXPECT(warden_relay_get(0) == false); + + /* set: idx >= COUNT (true) -> no-op */ + fk_reset(); + warden_relay_set(WARDEN_RELAY_COUNT, true); + EXPECT(fk.last_write_path[0] == '\0'); + + /* set: ensure_ready FAILS -> return before writing value */ + fk_reset(); fk.exists_ret = false; fk.export_makes_exist = false; + warden_relay_set(0, true); + EXPECT(strstr(fk.last_write_path, "/value") == NULL); + + /* set: ensure_ready OK (exported, "out"), on=true -> write "1" */ + fk_reset(); fk.exists_ret = true; fk.read_line_ok = true; fk.read_line_val = "out\n"; + warden_relay_set(0, true); + EXPECT(strcmp(fk.last_write_val, "1") == 0); + + /* set: on=false -> write "0" */ + fk_reset(); fk.exists_ret = true; fk.read_line_ok = true; fk.read_line_val = "out\n"; + warden_relay_set(0, false); + EXPECT(strcmp(fk.last_write_val, "0") == 0); +} + +/* ---------------- layer 2: real sysfs backend over a scratch tree ---------------- */ + +static char g_root[128]; + +static void wr(const char *rel, const char *content) { + char p[256]; snprintf(p, sizeof p, "%s/%s", g_root, rel); + FILE *f = fopen(p, "w"); if(f) { fputs(content, f); fclose(f); } +} +static void mkgpio(int n) { + char p[256]; snprintf(p, sizeof p, "%s/gpio%d", g_root, n); mkdir(p, 0777); +} + +static void integration_tests(void) +{ + char tmpl[] = "/tmp/warden-relays-XXXXXX"; + char *d = mkdtemp(tmpl); + assert(d); + snprintf(g_root, sizeof g_root, "%s", d); + setenv("WARDEN_GPIO_ROOT", g_root, 1); + warden_relay__set_io(NULL); /* restore the real sysfs backend */ + + /* empty $WARDEN_GPIO_ROOT -> gpio_root() falls back to the default (covers the + `*r` false arm of `r && *r`). The default /sys path is absent on the host, + so the call simply reports unavailable. */ + setenv("WARDEN_GPIO_ROOT", "", 1); + (void)warden_relay_available(0); + setenv("WARDEN_GPIO_ROOT", g_root, 1); + + /* sysfs_exists false + sysfs_write fopen FAIL: root has no /export parent issue; + use a nonexistent root so export write and stat both fail cleanly. */ + setenv("WARDEN_GPIO_ROOT", "/nonexistent-warden-root/xyz", 1); + EXPECT(warden_relay_available(0) == false); /* exists=false, write(export) fopen fails */ + setenv("WARDEN_GPIO_ROOT", g_root, 1); + + /* gpio32 exists, direction file ABSENT -> sysfs_read_line fopen fail */ + mkgpio(32); + EXPECT(warden_relay_available(0) == false); + + /* direction EMPTY -> fgets returns NULL -> read_line false */ + wr("gpio32/direction", ""); + EXPECT(warden_relay_available(0) == false); + + /* direction "out" -> read_line ok, skip block -> available true (sysfs_write success path + is exercised by set below); also value ABSENT -> get: read_int fopen fail -> v=0 */ + wr("gpio32/direction", "out\n"); + EXPECT(warden_relay_available(0) == true); + EXPECT(warden_relay_get(0) == false); /* value file absent: read_int fopen fail */ + + /* value non-numeric -> fscanf != 1 -> read_int false */ + wr("gpio32/value", "xyz\n"); + EXPECT(warden_relay_get(0) == false); + + /* value "1" -> read_int ok -> get true; and set(0,true) -> sysfs_write success */ + wr("gpio32/value", "1\n"); + EXPECT(warden_relay_get(0) == true); + warden_relay_set(0, false); + EXPECT(warden_relay_get(0) == false); /* wrote "0" over the value file */ + + /* direction "in" with a real value -> exercises the level-preserve write via sysfs */ + mkgpio(33); + wr("gpio33/direction", "in\n"); + wr("gpio33/value", "1\n"); + EXPECT(warden_relay_available(1) == true); /* reads value=1, writes direction "high" */ +} + +int main(void) +{ + unit_tests(); + integration_tests(); + fprintf(stderr, "%d checks, %d failures\n", g_checks, g_fail); + return g_fail ? 1 : 0; +}