Skip to content
4 changes: 3 additions & 1 deletion .specify/feature.json
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
{"feature_directory":"specs/006-level-sensors-ina226"}
{
"feature_directory": "specs/012-rev2-pin-map"
}
27 changes: 22 additions & 5 deletions firmware/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,16 +239,30 @@ Two board revisions exist, selected via Kconfig (`main/Kconfig.projbuild`):
manual RS485 DE pin, level sensors active HIGH).
- `CONFIG_BOARD_REV2` — custom PCB (THVD1426 auto-direction RS485 — no DE
pin, INA226 current monitors, level sensors active LOW via 2N7002 inverter).
Rev2 pins are provisional until hardware sync 1 (`TODO(SYNC1)` markers).
**Rev2 pins are FROZEN** (feature 012): they come from the SYNC 1 map in
`hardware/rev2/design-notes/02-mcu.md` §2.2 (frozen 2026-08-12), which each
rev2 pin group in `board.h` cites — a pin change goes schematic-first and
re-opens the profile deliberately. rev2 has **no buttons** (BOOT/RESET
only) and reserves IO18/19/23/4/27 for the expansion header J7; `board.h`
fails the build if a rev2 pin lands on that set (a rev2-only invariant —
rev1 legitimately uses IO18 and IO27).

All pins and polarity/feature flags come from `board/board.h`
(`BOARD_PIN_*`, `BOARD_HAS_RS485_DE`, `BOARD_HAS_RESERVOIR_PUMP`,
`BOARD_LEVEL_ACTIVE_LOW`, `BOARD_HAS_INA226`, `BOARD_NAME`). Never
`BOARD_LEVEL_ACTIVE_LOW`, `BOARD_HAS_INA226`, `BOARD_HAS_BTN_MANUAL`,
`BOARD_HAS_BTN_CONFIG`, `BOARD_HAS_VBAT_SENSE`, `BOARD_HAS_PWR_PG`,
`BOARD_HAS_SENS_PWR_EN`, `BOARD_NAME`). Never
hard-code GPIO numbers elsewhere. Board-conditional code uses
`#if CONFIG_BOARD_REV2` / `#if BOARD_HAS_INA226`. Enforcement pattern: a
capability flag at 0 leaves its pin/address macro UNDEFINED
(`BOARD_PIN_RS485_DE`, `BOARD_PIN_RESERVOIR_PUMP`, `BOARD_INA226_ADDR`),
so an unguarded reference is a compile error, never a phantom GPIO.
(`BOARD_PIN_RS485_DE`, `BOARD_PIN_RESERVOIR_PUMP`, `BOARD_INA226_ADDR`,
`BOARD_PIN_BTN_MANUAL`/`_CONFIG` on rev2, `BOARD_PIN_VBAT_SENSE`/
`_PWR_PG`/`_SENS_PWR_EN` on rev1), so an unguarded reference is a compile
error, never a phantom GPIO. The rev2-only power/rail signals
(`VBAT_SENSE` IO34 ADC1 input-only, `PWR_PG` IO35 input-only + external
pull-up, `SENS_PWR_EN` IO25 output with the rail OFF by hardware default)
are declared but have no consumers yet — drivers and rail sequencing are
PR-14 scope.

## BME280 environmental sensor (I2C)

Expand Down Expand Up @@ -375,7 +389,10 @@ watering. Credentials come from PR-06's `IConfigStore` (never logged, FR-004).
config button (`BOARD_PIN_BTN_CONFIG`, GPIO18, active LOW, >= 5 s hold, 100 ms
LED blink) → `decideBootMode` → provisioning (button-forced on a configured
device clears credentials first, per the data-model boot rule) or station
(`begin(Station)` + `wifi_task_start`). Kconfig: `WS_PROV_AP_SSID`,
(`begin(Station)` + `wifi_task_start`). The whole button read is compiled out
where `BOARD_HAS_BTN_CONFIG == 0` — rev2 has no button, so the
credentials-absent path is its sole provisioning trigger (feature 012 FR-003).
Kconfig: `WS_PROV_AP_SSID`,
`WS_PROV_AP_PASSWORD`, `WS_WIFI_*` reconnect constants. LED scope (parity
§7/§9): 500 ms connect-attempt toggle (wifi task) + 100 ms config-button-hold
blink (app_main); HIL checklist in `specs/007-wifi-provisioning/checklists/hil.md`.
Expand Down
297 changes: 272 additions & 25 deletions firmware/components/board/include/board/board.h

Large diffs are not rendered by default.

21 changes: 21 additions & 0 deletions firmware/main/app_main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@

static const char *TAG = "app_main";

#if BOARD_HAS_BTN_CONFIG
// Config-button emergency-provisioning hold (feature 007, US3). Parity
// (docs/parity-checklist.md §7): the config button held >= 5 s during startup
// forces WiFi provisioning; the status LED blinks every 100 ms while the hold
Expand All @@ -88,6 +89,7 @@ static const char *TAG = "app_main";
// connect-attempt toggle (that one runs later, from wifi_task.cpp).
static constexpr uint32_t kConfigButtonHoldMs = 5000; // hold to force prov.
static constexpr uint32_t kConfigButtonBlinkMs = 100; // LED toggle interval
#endif

/**
* @brief Drive every pump GPIO that exists on this board to a safe OFF
Expand Down Expand Up @@ -141,10 +143,19 @@ static void pumps_force_off(void)
}
}

#if BOARD_HAS_BTN_CONFIG
/**
* @brief Read the config button at boot and confirm a >= 5 s hold (feature
* 007, US3/T024/T026).
*
* Compiled ONLY on boards that actually have a config button
* (BOARD_HAS_BTN_CONFIG, feature 012 FR-003). The frozen rev2 board has no
* such button, and the pin this path used to read (IO18) is EXP_SCK on that
* board — an expansion-header signal the boot path must never touch, since
* expansion-bus traffic could be misread as a held button. On buttonless
* boards the credentials-absent path is the sole provisioning trigger
* (feature 007), unchanged.
*
* The config button (BOARD_PIN_BTN_CONFIG, GPIO18) is wired to GND and read
* with an internal pull-up, so it is active LOW: held == logic 0 (parity: the
* legacy INPUT_PULLUP idiom, same as the level-sensor inputs). Semantics:
Expand Down Expand Up @@ -222,6 +233,7 @@ static bool config_button_held_at_boot(void)
static_cast<unsigned long>(kConfigButtonHoldMs));
return true;
}
#endif /* BOARD_HAS_BTN_CONFIG */

extern "C" void app_main(void)
{
Expand Down Expand Up @@ -394,8 +406,17 @@ extern "C" void app_main(void)
// hold window. Credential VALUES are never logged: we only test whether an
// SSID is present. WiFi never touches the watering path (FR-014);
// everything below stays after the pump fail-safe.
//
// Boards without a config button (BOARD_HAS_BTN_CONFIG == 0, e.g. the
// frozen rev2) never take the button branch: the read is compiled out and
// the credentials-absent path is the sole provisioning trigger (feature
// 012 FR-003). No GPIO is claimed for a button that does not exist.
const bool wifi_credentials_present = !config.getWifiSsid().empty();
#if BOARD_HAS_BTN_CONFIG
const bool config_button_held = config_button_held_at_boot();
#else
const bool config_button_held = false;
#endif
const WifiBootMode wifi_boot_mode =
decideBootMode(wifi_credentials_present, config_button_held);

Expand Down
75 changes: 75 additions & 0 deletions firmware/test_apps/host/main/test_board_contract_rev1.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,33 @@
#define CONFIG_BOARD_REV1_DEVKIT 1
#include "board/board.h"

// FROZEN rev1 values (feature 012, FR-002). Feature 012 reshaped the rev2
// profile and must not have moved a single rev1 pin; these asserts are the
// durable guard, so a later rev2 edit that strays into the rev1 branch fails
// the host build instead of silently re-pinning the running bench rig.
// Source of truth: docs/parity-checklist.md, extracted from src/main.cpp.
static_assert(BOARD_PIN_I2C_SDA == 21 && BOARD_PIN_I2C_SCL == 22,
"rev1 board contract: I2C pins unchanged (FR-002)");
// RS485 TX=16/RX=17 per src/main.cpp — docs/hardware.md has them swapped
// (docs/parity-checklist.md QUIRK 6); the checklist wins.
static_assert(BOARD_PIN_RS485_TX == 16 && BOARD_PIN_RS485_RX == 17,
"rev1 board contract: RS485 UART pins unchanged (FR-002, "
"parity checklist QUIRK 6)");
static_assert(BOARD_HAS_RS485_DE == 1,
"rev1 board contract: manual direction control (SP3485 path)");
#ifndef BOARD_PIN_RS485_DE
#error "rev1 board contract: BOARD_PIN_RS485_DE must be defined"
#endif
static_assert(BOARD_PIN_RS485_DE == 25,
"rev1 board contract: RS485 DE pin unchanged (FR-002)");
static_assert(BOARD_RS485_UART_PORT == 2,
"rev1 board contract: Modbus RTU on UART2 (parity: legacy "
"Serial2, docs/parity-checklist.md §5)");
static_assert(BOARD_PIN_MAIN_PUMP == 26,
"rev1 board contract: plant pump pin unchanged (FR-002)");
static_assert(BOARD_PIN_STATUS_LED == 2,
"rev1 board contract: status LED pin unchanged (FR-002)");

// rev1 is the two-pump bench node: the capability flag is set AND the pin
// exists (flag ⇒ pin, the board.h consistency assert's positive branch).
static_assert(BOARD_HAS_RESERVOIR_PUMP == 1,
Expand Down Expand Up @@ -53,3 +80,51 @@ static_assert(BOARD_LEVEL_SETTLE_MS == 0,
"rev1 board contract: no settle gating (rail always on)");
static_assert(BOARD_LEVEL_DEBOUNCE_MS == 300,
"rev1 board contract: 300 ms debounce window");

// Buttons: the rev1 devkit rig HAS both buttons, and feature 012 must not
// change a single rev1 value (FR-002 regression guard). The manual-watering
// button sits on IO5, the WiFi-config button on IO18 — the pins the boot
// path reads today (feature 007, parity checklist §7).
//
// NOTE for future readers: feature 012 reserves IO18/19/23/4/27 for the
// rev2 expansion header J7, and rev1 legitimately uses two of them (IO18
// config button, IO27 reservoir pump). That reservation is a rev2-ONLY
// invariant (data-model.md invariant 3) — never "fix" the rev1 values to
// satisfy it.
static_assert(BOARD_HAS_BTN_MANUAL == 1,
"rev1 board contract: manual button present on the devkit rig");
#ifndef BOARD_PIN_BTN_MANUAL
#error "rev1 board contract: BOARD_PIN_BTN_MANUAL must be defined"
#endif
static_assert(BOARD_PIN_BTN_MANUAL == 5,
"rev1 board contract: manual button pin unchanged (FR-002)");
static_assert(BOARD_HAS_BTN_CONFIG == 1,
"rev1 board contract: config button present on the devkit rig");
#ifndef BOARD_PIN_BTN_CONFIG
#error "rev1 board contract: BOARD_PIN_BTN_CONFIG must be defined"
#endif
static_assert(BOARD_PIN_BTN_CONFIG == 18,
"rev1 board contract: config button pin unchanged (FR-002, "
"feature 007 boot provisioning path)");

// Power / rail signals: rev2-only hardware (feature 012, FR-005). The devkit
// rig runs off USB with a permanently powered sensor rail, so battery sense,
// buck power-good and sensor-rail enable do not exist here — flags 0 and pin
// macros undefined, so a PR-14 driver that references them without an
// #if BOARD_HAS_* guard fails the rev1 build instead of driving a phantom
// GPIO (the RS485-DE enforcement pattern).
static_assert(BOARD_HAS_VBAT_SENSE == 0,
"rev1 board contract: no battery voltage sense on the devkit rig");
#ifdef BOARD_PIN_VBAT_SENSE
#error "rev1 board contract: BOARD_PIN_VBAT_SENSE must NOT be defined"
#endif
static_assert(BOARD_HAS_PWR_PG == 0,
"rev1 board contract: no buck power-good on the devkit rig");
#ifdef BOARD_PIN_PWR_PG
#error "rev1 board contract: BOARD_PIN_PWR_PG must NOT be defined"
#endif
static_assert(BOARD_HAS_SENS_PWR_EN == 0,
"rev1 board contract: sensor rail is permanently on (no switch)");
#ifdef BOARD_PIN_SENS_PWR_EN
#error "rev1 board contract: BOARD_PIN_SENS_PWR_EN must NOT be defined"
#endif
105 changes: 102 additions & 3 deletions firmware/test_apps/host/main/test_board_contract_rev2.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,25 @@
#define CONFIG_BOARD_REV2 1
#include "board/board.h"

// FROZEN rev2 pin values (feature 012, FR-002). Every number below comes from
// the SYNC 1 map in hardware/rev2/design-notes/02-mcu.md §2.2 (frozen
// 2026-08-12): the board exists, so changing one here changes nothing in
// copper. Pinning them makes an accidental edit a build failure rather than a
// profile that silently disagrees with the PCB.
static_assert(BOARD_PIN_I2C_SDA == 21 && BOARD_PIN_I2C_SCL == 22,
"rev2 board contract: I2C pins per the frozen SYNC 1 map");
static_assert(BOARD_PIN_RS485_TX == 16 && BOARD_PIN_RS485_RX == 17,
"rev2 board contract: RS485 UART pins per the frozen SYNC 1 map");
static_assert(BOARD_RS485_UART_PORT == 2,
"rev2 board contract: Modbus RTU on UART2 (parity: legacy "
"Serial2, docs/parity-checklist.md §5)");
static_assert(BOARD_PIN_MAIN_PUMP == 26,
"rev2 board contract: plant pump pin per the frozen SYNC 1 map");
static_assert(BOARD_PIN_LEVEL_LOW == 32 && BOARD_PIN_LEVEL_HIGH == 33,
"rev2 board contract: level pins per the frozen SYNC 1 map");
static_assert(BOARD_PIN_STATUS_LED == 2,
"rev2 board contract: status LED pin per the frozen SYNC 1 map");

// rev2 is the single-pump node: capability flag 0 AND the pin REMOVED
// (flag=0 ⇒ pin undefined — the compile-error enforcement this feature's
// US2 rests on; same pattern as BOARD_PIN_RS485_DE).
Expand All @@ -25,9 +44,10 @@ static_assert(BOARD_HAS_RESERVOIR_PUMP == 0,
(unguarded references must fail the build)"
#endif

// rev2 carries the pump INA226 at 0x40 (A0 = A1 = GND; 0x41 reserved for
// the DNP solar footprint, 0x76/0x77 BME280 — the board-profile address
// map).
// rev2 carries the pump INA226 at 0x40 (A0 = A1 = GND). The board-profile
// address map also lists 0x41 (solar INA226 — populated on this node, see
// 01-power.md §1.5; its driver lands in PR-14) and 0x77 (BME280).
// BOARD_INA226_ADDR names the PUMP monitor specifically.
static_assert(BOARD_HAS_INA226 == 1,
"rev2 board contract: INA226 pump monitor present");
#ifndef BOARD_INA226_ADDR
Expand All @@ -52,3 +72,82 @@ static_assert(BOARD_HAS_RS485_DE == 0,
#ifdef BOARD_PIN_RS485_DE
#error "rev2 board contract: BOARD_PIN_RS485_DE must NOT be defined"
#endif

// Buttons: the frozen rev2 board has NO manual or config button — only the
// BOOT/RESET switches (IO0/EN) and the status LED (feature 012, FR-001).
// Both pin macros are therefore removed, so the boot path's button block
// cannot compile on this board (the same enforcement pattern as the
// reservoir pump above). This matters beyond tidiness: the pin the old
// profile used for BTN_CONFIG is IO18 = EXP_SCK, an expansion-header signal
// the boot path must never read.
static_assert(BOARD_HAS_BTN_MANUAL == 0,
"rev2 board contract: no manual button on the frozen rev2 board");
#ifdef BOARD_PIN_BTN_MANUAL
#error "rev2 board contract: BOARD_PIN_BTN_MANUAL must NOT be defined \
(unguarded references must fail the build)"
#endif
static_assert(BOARD_HAS_BTN_CONFIG == 0,
"rev2 board contract: no config button on the frozen rev2 board");
#ifdef BOARD_PIN_BTN_CONFIG
#error "rev2 board contract: BOARD_PIN_BTN_CONFIG must NOT be defined \
(unguarded references must fail the build)"
#endif

// Power / rail signals the frozen rev2 board provides (feature 012,
// FR-005). Consumers (ADC calibration, PG monitoring, rail sequencing) are
// PR-14 scope; the profile only has to state the facts.
static_assert(BOARD_HAS_VBAT_SENSE == 1,
"rev2 board contract: battery voltage sense present");
#ifndef BOARD_PIN_VBAT_SENSE
#error "rev2 board contract: BOARD_PIN_VBAT_SENSE must be defined"
#endif
static_assert(BOARD_PIN_VBAT_SENSE == 34,
"rev2 board contract: VBAT_SENSE on IO34 (ADC1_CH6, input-only)");

static_assert(BOARD_HAS_PWR_PG == 1,
"rev2 board contract: buck power-good present");
#ifndef BOARD_PIN_PWR_PG
#error "rev2 board contract: BOARD_PIN_PWR_PG must be defined"
#endif
static_assert(BOARD_PIN_PWR_PG == 35,
"rev2 board contract: PWR_PG on IO35 (input-only, ext. pull-up)");

static_assert(BOARD_HAS_SENS_PWR_EN == 1,
"rev2 board contract: switched sensor rail present");
#ifndef BOARD_PIN_SENS_PWR_EN
#error "rev2 board contract: BOARD_PIN_SENS_PWR_EN must be defined"
#endif
static_assert(BOARD_PIN_SENS_PWR_EN == 25,
"rev2 board contract: SENS_PWR_EN on IO25 (output, rail OFF "
"by hardware default)");

// Expansion reservation (feature 012, FR-004). J7 carries VSPI
// SCK=IO18, MISO=IO19, MOSI=IO23 plus CS=IO4 and IRQ=IO27
// (08-expansion.md §8.3); core firmware must not
// claim any of them on rev2. board.h enforces this too — this TU is the
// belt to that header's braces: the contract survives even if the header
// check is ever removed. NOTE: this is a rev2-ONLY invariant; rev1
// legitimately uses IO18 (config button) and IO27 (reservoir pump).
#define WS_REV2_NOT_EXPANSION(pin) \
((pin) != 18 && (pin) != 19 && (pin) != 23 && (pin) != 4 && (pin) != 27)

static_assert(WS_REV2_NOT_EXPANSION(BOARD_PIN_I2C_SDA) &&
WS_REV2_NOT_EXPANSION(BOARD_PIN_I2C_SCL),
"rev2 board contract: I2C pins must stay off the expansion set");
static_assert(WS_REV2_NOT_EXPANSION(BOARD_PIN_RS485_TX) &&
WS_REV2_NOT_EXPANSION(BOARD_PIN_RS485_RX),
"rev2 board contract: RS485 pins must stay off the expansion set");
static_assert(WS_REV2_NOT_EXPANSION(BOARD_PIN_MAIN_PUMP),
"rev2 board contract: pump pin must stay off the expansion set");
static_assert(WS_REV2_NOT_EXPANSION(BOARD_PIN_LEVEL_LOW) &&
WS_REV2_NOT_EXPANSION(BOARD_PIN_LEVEL_HIGH),
"rev2 board contract: level pins must stay off the expansion set");
static_assert(WS_REV2_NOT_EXPANSION(BOARD_PIN_STATUS_LED),
"rev2 board contract: status LED must stay off the expansion set");
static_assert(WS_REV2_NOT_EXPANSION(BOARD_PIN_VBAT_SENSE) &&
WS_REV2_NOT_EXPANSION(BOARD_PIN_PWR_PG) &&
WS_REV2_NOT_EXPANSION(BOARD_PIN_SENS_PWR_EN),
"rev2 board contract: power/rail pins must stay off the "
"expansion set");

#undef WS_REV2_NOT_EXPANSION
49 changes: 49 additions & 0 deletions specs/012-rev2-pin-map/checklists/requirements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Specification Quality Checklist: rev2 board profile aligned with frozen hardware

**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-08-12
**Feature**: [spec.md](../spec.md)

## Content Quality

- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed

## Requirement Completeness

- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified

## Feature Readiness

- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification

## Notes

- **2026-08-12 (same day): spec rewritten after codebase survey.** The first
draft assumed the reservoir-pump/IO27 divergence still existed; origin/main
already fixed it in phases 1–3. The survey found the actually-remaining
divergences (phantom buttons with BTN_CONFIG on EXP_SCK/IO18, three missing
signals, stale markers). All checklist items re-validated against the
rewritten spec — still passing.

- GPIO numbers appear throughout the spec. They are retained deliberately: pin
assignments are frozen *hardware facts* (the contract this feature encodes),
not implementation choices. The spec would be untestable without them.
- The mechanism for board-conditional pump handling (count macro vs capability
flags) is intentionally left to the plan; FR-003 states only the required
property (derived from profile, not hard-coded).
- No [NEEDS CLARIFICATION] markers were needed: scope boundaries (definitions
in, drivers out), safety constraints (constitution I) and both boards'
expected behavior are fully determined by existing project documents.
Loading
Loading